<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title><![CDATA[Tiger Data Blog]]></title>
        <description><![CDATA[Insights, product updates, and tips from TigerData (Creators of TimescaleDB) engineers on Postgres, time series & AI. IoT, crypto, and analytics tutorials & use cases.]]></description>
        <link>https://www.tigerdata.com/blog</link>
        <image>
            <url>https://www.tigerdata.com/icon.ico</url>
            <title>Tiger Data Blog</title>
            <link>https://www.tigerdata.com/blog</link>
        </image>
        <generator>RSS for Node</generator>
        <lastBuildDate>Thu, 08 Oct 2026 16:22:59 GMT</lastBuildDate>
        <atom:link href="https://www.tigerdata.com/blog" rel="self" type="application/rss+xml"/>
        <ttl>60</ttl>
        <item>
            <title><![CDATA[The Engineering Calendar Is the Database Bill Nobody Tracks]]></title>
            <description><![CDATA[The cost of the Optimization Treadmill doesn't show up on the database bill. It shows up on the engineering calendar. And it compounds in ways that are easy to miss until someone actually adds it up.]]></description>
            <link>https://www.tigerdata.com/blog/the-engineering-calendar-is-the-database-bill-nobody-tracks</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/the-engineering-calendar-is-the-database-bill-nobody-tracks</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Tips]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Tue, 02 Jun 2026 13:54:23 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/05/The-Engineering-Time-Sink-Nobody-Talks-About.png">
            </media:content>
            <content:encoded><![CDATA[<p>The database bill went up 40% last quarter, and everybody noticed. Finance noticed. You had a meeting about it. Somebody made a slide with a trend line that looked bad enough to earn its own agenda item.</p><p>The other database bill probably did not make the deck. It lives on the engineering calendar: weekly database reviews, monthly capacity checks, Slack threads about replica lag, onboarding sessions where a senior engineer explains why the partitioning scheme is the way it is, and the quarterly planning meeting where "database scalability" shows up again. I think of this as "calendar debt".</p><p><strong>Calendar debt</strong> is what happens when an architectural mismatch stops showing up only as performance pain and starts changing how the team spends its time. The work is legitimate. Debugging write latency, tuning autovacuum, and reviewing partition migrations all require real engineering judgment.</p><p>The problem is that this work keeps coming back. At some point, the database is not just consuming CPU, storage, and cloud budget. It is consuming the attention of the people who are supposed to be building the product.</p><h2 id="run-a-90-day-calendar-audit">Run a 90-day calendar audit</h2><p>Before arguing about whether the database needs <a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"><u>a different architecture</u></a>, look at the last 90 days of work. You do not need perfect time tracking. You need a useful approximation.</p><p>Pull the incident tickets with database root causes: slow queries, replica lag, connection pool exhaustion, WAL growth, autovacuum backlog, failed partition jobs, index bloat, storage pressure. Count the response time and the follow-up work. The incident is rarely the whole cost, because the real time usually shows up in the cleanup, the postmortem, the monitoring tweak, and the "we should make sure this never happens again" task that becomes someone's afternoon.</p><p>Search Slack for the terms that usually mean real work is happening: <code>autovacuum</code>, <code>partition</code>, <code>vacuum</code>, <code>replica lag</code>, <code>WAL</code>, <code>bloat</code>, <code>statistics</code>, <code>index rebuild</code>, <code>capacity</code>. Look at sprint work and meeting titles. Count query tuning, partition management, retention cleanup, schema migration support, capacity planning, runbook updates, and recurring reviews with "database" in the title.</p><p>Then ask the senior engineers directly: how much time last week went to database work that was not directly building product? Ask it as a systems question, not a performance question. Nobody needs a tiny productivity court hiding inside a database conversation. The point is whether the architecture is creating recurring work.</p><p>That recurrence is the signal. One slow query is an incident. A standing meeting about slow queries is architecture becoming process. One partition failure is a bug. A recurring partition review is lifecycle management leaking onto the calendar.</p><p>If the same names keep showing up in the tickets, Slack threads, sprint tasks, and meeting invites, the infrastructure bill is only part of what you are paying. You are carrying calendar debt too.</p><h2 id="what-the-debt-looks-like">What the debt looks like</h2><p>On a high-ingest Postgres workload, calendar debt usually starts small. One slow dashboard query becomes an index review. One retention problem becomes a partitioning discussion. One write latency spike becomes an autovacuum tuning session. One schema migration gets delayed because it touches too many partitions and nobody wants to find out in production that the migration plan was optimistic.</p><p>All of that is reasonable work. That is why it is easy to miss. It becomes a standing category of work.</p><p><a href="https://www.tigerdata.com/blog/hidden-costs-table-partitioning-scale"><u>Partition management</u></a> means creating future partitions, checking for gaps, validating the automation, and handling the incident when a missing partition breaks ingestion at the least convenient possible hour. If you have been on the receiving end of that alert, you already know this is not an abstract problem.</p><p><a href="https://www.tigerdata.com/blog/the-autovacuum-tax"><u>Autovacuum tuning</u></a> means watching <code>pg_stat_activity</code>, changing per-table settings as data volume changes, and figuring out whether a write latency spike is actually I/O contention from vacuum activity. Index maintenance means tracking bloat, rebuilding indexes, and debating whether a new read-path index is worth the extra <a href="https://www.tigerdata.com/blog/write-amplification-in-postgres-the-3-4x-tax-on-every-insert"><u>write amplification</u></a> on a table that already takes continuous inserts.</p><p>Replication management means watching lag, tuning <code>max_wal_size</code>, and dealing with the WAL accumulation alert when a replica falls behind during a write peak. Capacity planning means projecting data growth, modeling the next <a href="https://www.tigerdata.com/blog/vertical-scaling-buying-time-you-cant-afford"><u>vertical scaling</u></a> event, writing the infrastructure ticket, and explaining why the database needs more money after it got more money last quarter.</p><p>Individually, these tasks feel too small to count. Twenty minutes here. An hour there. A planning meeting. A follow-up review. A "quick" Slack thread that is somehow still active after lunch.</p><p>Aggregated across a week, they become a day. For senior engineers on teams deep into the Optimization Treadmill, 20-30% of their time can disappear this way. That number sounds high until you actually count the work instead of remembering it.</p><p>Memory rounds down. Calendars do not.</p><h2 id="the-calendar-is-the-leading-indicator">The calendar is the leading indicator</h2><p>The cloud bill tells you what happened after the workload grew. The calendar tells you what is going to keep happening if nothing changes.</p><p>A one-time tuning sprint might be normal. A recurring tuning sprint is a strategy, even if nobody meant to make it one. The same goes for recurring capacity reviews, recurring partition checks, recurring autovacuum investigations, recurring schema migration reviews, and recurring "can we ship this dashboard without making the database sad?" conversations.</p><p>Each one is a small admission that the system requires ongoing human coordination to stay acceptable. The invoice can tell you the instance got more expensive. It cannot tell you the team has accepted a permanent tax on planning, onboarding, incident response, and senior engineering attention.</p><p>This is where the decision gets harder, because the people who understand the database path are the same people you need to change it. When their calendar is already full of maintenance, the work that would reduce the maintenance keeps moving out by a quarter.</p><p>That is the loop. The current architecture creates recurring work. The recurring work consumes the time needed to change the architecture. The data keeps growing while everyone waits for a clean window that never arrives. Not great.</p><h2 id="onboarding-is-where-the-debt-becomes-obvious">Onboarding is where the debt becomes obvious</h2><p>Existing teams normalize their own weirdness. The partition naming convention makes sense because everyone remembers the incident that created it. The autovacuum thresholds make sense because someone tuned them six months ago after a write peak. The runbook makes sense because the people reading it already know the missing context.</p><p>Then a new engineer joins, and suddenly the team has to explain all of it from scratch. Why the partitions are named that way. How the <code>pg_partman</code> automation works and what happens when it fails. Which <code>autovacuum</code> alerts are noisy until the day they are not. How schema migrations work across hundreds of partitions. Which replica is safe to query. Which incident from two years ago explains the one thing in the runbook that otherwise looks completely unhinged.</p><p>This is operational folklore, not product knowledge. It lives in runbooks, Slack history, and the heads of the two or three engineers who were there when the decisions were made.</p><p>So onboarding takes three or four weeks before someone can safely operate the database path. During that time, the new hire is less productive and the senior engineers are doing support work. Every hire pays that cost again.</p><p>The runbooks also have their own bill. Writing them takes time. Keeping them current takes time. When the partitioning scheme changes, someone has to know to update the docs, find the docs, and then actually update the docs. Documentation debt on operational procedures accumulates the same way technical debt does. It just looks more respectable because it has headings.</p><h2 id="the-debt-compounds-with-data-volume">The debt compounds with data volume</h2><p>The shape of the problem matters. At 100 million rows, the partitioning scheme may be manageable: maybe 50 partitions, a few runbooks, and one engineer who really understands the sharp edges. Database operations might take 10% of engineering time.</p><p>At 500 million rows, the partition count has grown. Autovacuum tuning is more complicated. A few incidents have added new alerts, new checklists, and new exceptions. The original expert has either become the bottleneck or has left enough knowledge behind to make everyone nervous. Now the work is closer to 20%.</p><p>At a billion rows and beyond, the scheme is embedded in how the team operates. Schema migrations are multi-day projects. Onboarding has a dedicated database section. Quarterly planning became monthly planning without anyone formally deciding that should happen. At that point, 30% is not a dramatic estimate. It is the floor on a bad quarter.</p><p>The growth is not linear because operational surface area does not grow one-to-one with data volume. Each threshold creates new work: more partitions, more monitoring, more migration caution, more review paths, more tribal knowledge.</p><p>Meanwhile, product work slows down in the most annoying possible way: gradually. Nobody flips a table. Features just take a little longer because database changes require more review. Releases get a little more careful because the partition scheme adds risk. The roadmap gets a quiet asterisk on every data-heavy feature: check with the database people first. That is how you know the architecture has become a product constraint.</p><h2 id="what-changes-when-the-architecture-matches-the-workload">What changes when the architecture matches the workload</h2><p>This is the part where vendor content usually gets hand-wavy, so let's be specific. Database work does not become zero. You still operate a database. You still care about schema design, query behavior, retention, capacity, and reliability. The useful question is which calendar items should stop existing.</p><p>Take the recurring partition review. If that meeting exists because the team has to create future partitions, check for gaps, validate automation, and explain <code>pg_partman</code> failure modes to every new engineer, that is lifecycle work sitting in a meeting invite. Hypertables move time-based partitioning into the table abstraction. Chunks are created automatically as data arrives, so the partition creation job and the gap-monitoring ritual stop being monthly team activities.</p><p>Take the retention cleanup thread. If engineers are debating <a href="https://www.tigerdata.com/blog/moving-from-row-deletes-to-instant-data-retention"><u>row deletes</u></a>, manual partition drops, and cleanup windows every time data ages out, retention has become process. A retention policy turns that into database behavior. Expired chunks can be dropped by policy rather than by a quarterly cleanup project everyone swears will be simple this time.</p><p>Take the autovacuum investigation that keeps coming back. If the team is repeatedly tuning vacuum behavior around older high-volume data, the storage model is making historical data operationally expensive. <a href="https://www.tigerdata.com/blog/hypercore-a-hybrid-row-storage-engine-for-real-time-analytics"><u>Hypercore</u></a> moves older chunks into a columnar format. Vacuum does not disappear from Postgres, but the recurring work created by high-ingest row churn on data that is no longer actively modified gets smaller.</p><p>Take the schema migration review. If every migration requires a special conversation because the table is really hundreds of manually managed partitions wearing a trench coat, the abstraction is leaking. With Hypertables, the application still sees a table. The migration discussion gets smaller because the lifecycle machinery is not scattered across a partition tree the team has to reason about by hand.</p><p>The calendar changes because whole categories of recurring work shrink or disappear. No partition creation review next month, no gap-monitoring script to babysit, fewer autovacuum conversations about old high-volume data, and less onboarding time spent explaining why the lifecycle machinery works the way it does.</p><p>Same Postgres ecosystem. Different operational shape. That is the actual value.</p><h2 id="bring-your-calendar-to-the-architecture-conversation">Bring your calendar to the architecture conversation</h2><p>The cloud bill is visible. It shows up in the budget report with a trend line and a year-over-year comparison. The engineering calendar usually does not.</p><p>That is why teams undercount database cost. The work is distributed across incident tickets, sprint tasks, Slack threads, onboarding sessions, planning meetings, and "quick reviews" that are never quite quick.</p><p>If you want to know whether database optimization is still the right path, start with the calendar. Count the incidents. Count the meetings. Count the onboarding time. Count the senior engineer hours that went to keeping the database acceptable instead of moving the product forward.</p><p>Then ask the better question: is this optimization work buying us a better architecture, or is it paying interest on the current one? At 50 million rows, changing direction might take a week. At a billion rows, it can take months. Waiting does not make the work cheaper. It usually adds more runbooks.</p><p>If you want the mechanical side of why this happens, read <a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>Understanding Postgres Performance Limits for Analytics on Live Data</u></a>. It explains the Optimization Treadmill and the architectural constraints behind it.</p><p>Bring your calendar when you read it. That is where the real bill is hiding.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The True Cost of Database Optimization: Engineering Time]]></title>
            <description><![CDATA[The true cost of Postgres optimization isn't the cloud bill. It's 12-16 engineer-weeks per year that never show up on a budget report.]]></description>
            <link>https://www.tigerdata.com/blog/the-true-cost-of-database-optimization-engineering-time</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/the-true-cost-of-database-optimization-engineering-time</guid>
            <category><![CDATA[Database]]></category>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Thu, 14 May 2026 20:36:27 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/05/thumbnail-blog--1-.png">
            </media:content>
            <content:encoded><![CDATA[<p>"We can fix the performance issue with better indexes, smarter partitioning, and some vacuum tuning. It's cheaper than switching."</p><p>You've heard this sentence. You may have said this sentence.</p><p>The optimization wasn't cheap. It just felt like it was.</p><p>"Cheaper than what" is the question nobody asks. The optimization doesn't show up on an invoice. It costs engineering time. And engineering time has a rate: the fully-loaded cost of the senior engineers doing the work, plus whatever those engineers aren't building while they're doing it. Most teams have never actually added up their database optimization spend. When they do, the number is larger than expected. And it comes back every quarter.</p><p>This problem is specific to a particular class of workload: high-frequency, append-heavy data. Telemetry, metrics, events, anything where timestamps are how you think about your data and the table only ever gets bigger. If that describes your system, keep reading. If you're running a CRUD app with predictable write volume, this isn't your problem.</p><h2 id="why-optimization-doesnt-fix-this">Why optimization doesn't fix this</h2><p>Here's what most teams figure out a year or two in: optimization isn't the wrong thing to try. It's just solving the wrong problem.</p><p>Tuning vanilla Postgres for a high-frequency append workload is a bit like upgrading the engine on a pickup truck because you want to haul more freight. You can make the truck faster and it feels productive. But at some point, you're limited by what the vehicle fundamentally is. The problem isn't the mechanic. It's the vehicle.</p><p>When your workload is structurally mismatched to your database architecture, the optimization treadmill is inevitable. Every index you add, every partition scheme you design, every autovacuum you tune: it's solving for a data volume you'll outgrow in months. The gap between "current optimization" and "needed optimization" widens every quarter. Not because you're falling behind. Because the data compounds faster than the fixes do.</p><h2 id="a-realistic-year">A realistic year</h2><p>Here's what that looks like. A year of Postgres optimization for a high-volume append workload.</p><p><strong>Q1.</strong> Queries are slowing down. A senior engineer spends two weeks analyzing query plans, adding targeted indexes, and rewriting three critical queries. Performance improves. Write throughput drops roughly 15% because of new index maintenance overhead. (These numbers are illustrative. Your Q1 will have its own version of this tradeoff.)</p><p><strong>Q2.</strong> Table size is causing partition-related issues. The team implements time-based partitioning. Two engineers spend three weeks on it: designing the partition scheme, migrating existing data, updating application queries that assumed a single table, and fixing the CI/CD pipeline that didn't account for partition management.</p><p><strong>Q3.</strong> Autovacuum is competing with production writes during peak hours. One engineer spends a week tuning autovacuum parameters, adjusting cost delays, and setting up monitoring for vacuum lag. A follow-up incident two weeks later, when a vacuum job blocks a schema migration, costs another three days.</p><p><strong>Q4.</strong> Storage costs are climbing. The team evaluates compression options, considers archiving old data to cold storage, and ultimately decides to upgrade the instance size to buy headroom for Q1 of next year. The upgrade takes a day. The evaluation and planning took two weeks.</p><p>Total: 12 to 16 engineer-weeks across the year. At fully-loaded senior engineer cost (call it $150K to $200K/year), that's $35K to $60K in direct labor. You bought time, not a solution. And the bill comes back next year.</p><h2 id="the-opportunity-cost-the-real-number">The opportunity cost (the real number)</h2><p>The $35K to $60K understates it.</p><p>12 to 16 engineer-weeks is a feature. It's a product launch. For a team of 10, that's 3 to 4% of total engineering output spent keeping the database at "acceptable." Not advancing it. Just treading water against a growing dataset.</p><p>Ask your engineering manager: if you reclaimed those 12 to 16 weeks, what would you build? That's the true cost of optimization. Not the hours. The roadmap you didn't ship.</p><p>And it compounds. Year two has all the same optimization needs plus new ones as data grows, but now you're also maintaining the partitioning scheme from Q2 and the vacuum configuration from Q3. The baseline maintenance burden grows even as new problems arrive.</p><p><a href="https://www.tigerdata.com/blog/how-flogistix-by-flowco-reduced-infrastructure-management-costs-by-66-with-tiger-data"><u>Flogistix</u></a>, who runs high-frequency oil and gas telemetry, reported 66% monthly cost savings after moving to Tiger Cloud, and their engineering team said the freed time directly increased roadmap velocity. That's what the other side of this decision looks like.</p><h2 id="the-hidden-costs-nobody-tracks">The hidden costs nobody tracks</h2><p>These don't show up in sprint planning.</p><p><strong>Incident response.</strong> Database performance incidents pull engineers off planned work. A slow query that triggers alerts at 2am costs the on-call engineer a night of sleep and a mostly useless next day. These incidents increase in frequency as the gap between "current optimization" and "needed optimization" widens. And the gap always widens.</p><p><strong>Knowledge concentration.</strong> Database optimization work accumulates in one or two senior engineers who understand the schema, the query patterns, and enough Postgres internals to make changes safely. This is your single point of failure. When that engineer is on vacation or leaves, optimization work stalls or gets done slowly by someone learning as they go. Trust me, I've seen this play out in ways that aren't fun for anyone involved.</p><p><strong>Context switching.</strong> Engineers don't work on database optimization in clean, uninterrupted blocks. They get pulled in for an afternoon here, a day there, to diagnose a regression or review a partition change. Context switching is expensive because it disrupts both the database work and whatever they were doing before. You're not just paying for the time spent on the database. You're paying for the interrupt tax on everything else.</p><p>All three are part of the platform tax: the invisible engineering cost of maintaining infrastructure that doesn't quite fit the workload. It doesn't show up on an invoice either.</p><h2 id="calculate-your-own-number">Calculate your own number</h2><p>Track for one month. Count hours spent on: query optimization and explain plan analysis; partition management and creation; autovacuum tuning, monitoring, and incident response; database-related incident response (slow query alerts, replication lag, connection pool exhaustion); and meetings discussing performance, capacity planning, or migration timing.</p><p>Multiply the monthly total by 12. Multiply that by the fully-loaded hourly rate of the engineers involved. That's your annual optimization cost.</p><p>Compare it against the one-time cost of migrating to a system designed for the workload (typically 2 to 8 engineer-weeks depending on data volume), plus ongoing maintenance that scales with workload complexity rather than with data growth.</p><p>For most teams, the breakeven is within the first year. Often within the first quarter. Do the math before assuming migration is the expensive option.</p><h2 id="what-the-alternative-looks-like">What the alternative looks like</h2><p>After migrating to <a href="https://www.tigerdata.com/docs/learn/hypertables/understand-hypertables"><u>TimescaleDB</u></a> (the open-source Postgres extension that powers Tiger Cloud), the engineering time picture looks different.</p><p>Migration cost: one-time, typically 1 to 4 weeks for a single engineer depending on data volume and schema complexity. Most of that time is data backfill, not application changes. TimescaleDB is still Postgres. Your SQL, your tooling, your team's existing knowledge stays intact.</p><p>Ongoing costs: not zero, but different in kind. The categories of work that consumed engineering time on vanilla Postgres shift significantly. Automatic partitioning via <a href="https://www.tigerdata.com/docs/learn/hypertables/understand-hypertables"><u>Hypertables</u></a> removes partition management as a recurring quarterly project. The database handles it. Compression policies run automatically in the background. Autovacuum pressure on historical data drops because <a href="https://www.tigerdata.com/docs/learn/columnar-storage/understand-hypercore"><u>Hypercore</u></a> converts older chunks to columnar format: instead of accumulating MVCC dead tuples on row-level records, that data is stored as compressed column arrays that don't generate the same vacuum workload. You still tune a database. You just stop tuning the same problems every quarter.</p><p>What was being spent on keeping vanilla Postgres at "acceptable" is now available for product work. Not because the database is magic. Because the architecture fits the workload.</p><h2 id="the-decision-you-keep-deferring">The decision you keep deferring</h2><p>The true cost of database optimization is not the cloud bill. It's the engineering time: senior engineers spending weeks per quarter on maintenance that keeps the system at "acceptable" rather than moving it forward.</p><p>If the annual optimization cost exceeds the one-time migration cost (and it usually does, often within the first year), the economic case writes itself. The harder question is whether the team can keep deferring the decision, knowing that each quarter of optimization increases the total spend without changing the trajectory.</p><p>Run the numbers. Then decide.</p><p>If you've done the math and want to understand what migration looks like at your data scale, <a href="https://www.tigerdata.com/blog/when-to-migrate-postgres-to-timescaledb"><u>The Best Time to Migrate Was at 10M Rows. The Second Best Time Is Now.</u></a> is a good next read. And when you're ready to move, the <a href="https://www.tigerdata.com/docs/deploy/self-hosted/migration"><u>migration guide</u></a> covers the mechanics.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Five Warning Signs Your Database Needs Different Architecture]]></title>
            <description><![CDATA[There's a category of Postgres performance issue that no amount of tuning will fix. Here's how to tell if you're already in it.]]></description>
            <link>https://www.tigerdata.com/blog/five-warning-signs-your-database-needs-different-architecture</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/five-warning-signs-your-database-needs-different-architecture</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Thu, 07 May 2026 11:40:41 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/05/thumbnail-blog-thumbnail-1280x720--4-.png">
            </media:content>
            <content:encoded><![CDATA[<p>Every database has bad days. A slow query after a schema change. A spike in replication lag during a traffic surge. An autovacuum job that runs long enough to make you nervous.</p><p>Those are tuning problems. They have tuning solutions. A senior engineer can fix them in a day, sometimes in an hour, and the fix holds. You move on.</p><p>But there's a different category of symptom. The kind that comes back. The kind where each solution introduces the need for the next solution, where performance degrades not because of a specific bad query but because the workload has grown into territory the database wasn't designed for.</p><p>Here's the thing that makes this category different: growth changes the <em>nature</em> of the problem, not just the severity. When your workload is fundamentally append-heavy and analytically queried, you're running it on a storage engine built for point lookups and updates. That mismatch doesn't show up at small scale. It compounds. And eventually no amount of configuration adjusts for it.</p><p>I've watched teams burn two quarters optimizing their way around this before realizing the optimization itself was the trap. The five signs below are how you recognize it before that happens.</p><p>If you're seeing three or more, the problem isn't your configuration.</p><h2 id="sign-1-your-optimization-work-is-cyclical-not-cumulative">Sign 1: Your optimization work is cyclical, not cumulative</h2><p>Tuning that sticks is cumulative. You add an index, queries get faster, they stay faster. You adjust <code>work_mem</code>, hash joins improve, the improvement is permanent. Done.</p><p>The warning sign: you're doing the same work again six months later. You partition the table, and after a while you need to re-partition because the data distribution shifted. You optimize a query, and three months later the same query is slow again because the table grew 40%. You upgrade the instance, and the headroom is gone in a quarter.</p><p>When optimization is cyclical, the underlying cause is growth, not misconfiguration. Growth doesn't respond to configuration. It responds to architecture.</p><p><strong>How to measure:</strong> look at your sprint backlog. How many database-related tasks do you have this quarter versus last quarter versus the one before that? If the number is flat or growing, your optimization is running in place.</p><h2 id="sign-2-autovacuum-is-running-constantly-on-tables-with-low-update-rates">Sign 2: Autovacuum is running constantly on tables with low update rates</h2><p>Autovacuum on a heavily updated table makes sense. Dead tuples need cleaning, that's the job.</p><p>The warning sign: autovacuum running persistently on tables where fewer than 5% of rows are ever updated. This usually means one of two things: transaction ID wraparound prevention driving vacuum on high-insert tables, or hint bit maintenance generating constant background I/O.</p><p>Both are <a href="https://www.tigerdata.com/blog/mvcc-feature-youre-paying-for-but-not-using"><u>MVCC overhead</u></a> on append-only data. The visibility tracking machinery runs at full cost on rows that will never be modified. Tuning autovacuum parameters (<code>scale_factor</code>, <code>cost_delay</code>, <code>max_workers</code>) adjusts how the work is distributed, not whether it has to happen. The work still has to happen.</p><p><strong>How to measure:</strong> check <code>pg_stat_user_tables</code> for your largest tables. Compare <code>n_dead_tup to autovacuum_count</code>. If autovacuum runs frequently but dead tuple counts are consistently low, the vacuum is maintenance overhead, not cleanup. You're paying the full MVCC tax on data that never gets modified.</p><h2 id="sign-3-query-performance-degrades-linearly-with-data-volume">Sign 3: Query performance degrades linearly with data volume</h2><p>Postgres query performance should be sublinear with data volume when indexes are working correctly. A B-tree index lookup is O(log n). Adding 10x more data should add a constant factor to indexed lookups, not 10x more time.</p><p>The warning sign: queries that get proportionally slower as the table grows. This happens when the dominant pattern is sequential scans or index scans over wide ranges rather than point lookups. Aggregations over time ranges, <code>GROUP BY</code> on large result sets, analytical scans touching millions of rows. These scale with data volume, not with the index.</p><p>Partitioning helps by limiting scans to relevant partitions. It's also a manual process that requires ongoing management, and partition-level sequential scans still scale linearly within each partition.</p><p><strong>How to measure:</strong> run your five slowest queries against progressively larger time ranges. If execution time scales proportionally with the range, the queries are scan-bound. More data will always mean slower queries, and no index is going to change that trajectory.</p><h2 id="sign-4-index-strategy-has-become-a-tradeoff-negotiation">Sign 4: Index strategy has become a tradeoff negotiation</h2><p>In a well-matched architecture, indexes are straightforward. Add them where you query, the read performance benefit outweighs the write cost, everyone's happy.</p><p>The warning sign: adding indexes to improve read performance measurably degrades write throughput, and the team is now making deliberate tradeoffs. "We can't add that index, it would slow inserts below our SLA." That conversation is the sign.</p><p>This is specific to high-throughput insert workloads. A SaaS app doing 1K inserts/sec can add ten indexes without noticing. A telemetry pipeline doing 100K inserts/sec feels every additional index in <a href="https://www.tigerdata.com/blog/write-amplification-in-postgres-the-3-4x-tax-on-every-insert"><u>write amplification</u></a>. The math is just different.</p><p>When index strategy becomes a negotiation between read and write performance, the workload has outgrown what B-tree indexing on row-oriented storage can serve without tradeoffs. You can't index your way out.</p><p><strong>How to measure:</strong> benchmark write throughput with your current index set, then with one additional index. If the drop is more than 10-15%, you're in the tradeoff zone. Whoops.</p><h2 id="sign-5-storage-costs-are-growing-faster-than-data-value">Sign 5: Storage costs are growing faster than data value</h2><p>Postgres stores data at the row level, uncompressed (unless you're using TOAST for large values). For time-series and event data, raw storage cost is proportional to row count with no automatic lifecycle management built in.</p><p>The warning sign: storage costs growing linearly with data volume, and the team having conversations about retention not because old data is worthless, but because storing it is expensive. You archive to cold storage or drop old partitions to manage costs, even though you'd rather keep the data online.</p><p>When storage cost forces data lifecycle decisions, the storage model isn't efficient for the data type. Columnar compression (10-20x for typical time-series data) and automatic data tiering change the economics so that keeping years of history queryable is practical rather than a monthly budget conversation.</p><p><strong>How to measure:</strong> calculate your cost per million rows stored. Compare that number to the analytical value of the data. If you're deleting or archiving data you'd rather be querying, storage cost is constraining your product. That's the sign.</p><h2 id="the-diagnostic">The diagnostic</h2><p>Count how many of these apply to your system.</p><p><strong>0-1:</strong> Tuning is the right move. Your workload fits the architecture, specific optimizations will address the symptoms. The usual playbook works.</p><p><strong>2-3:</strong> You're approaching the boundary. Current optimizations will buy months, not years. Start evaluating architectural options now, while you still have time to choose rather than react.</p><p><strong>4-5:</strong> The workload has outgrown the architecture. Further optimization has diminishing returns. Migration to a system designed for this workload will be less expensive than continued optimization within 6-12 months. Not a fun conclusion, but better to reach it now than after another year of sprint-backlog churn.</p><h2 id="what-this-actually-means">What this actually means</h2><p>Tuning problems have solutions that stick. Architectural mismatches have solutions that buy time. The goal of this whole exercise is to figure out which one you're dealing with before you've spent another quarter on the latter.</p><p>If the diagnostic puts you in the 4-5 range, the answer isn't to abandon Postgres. The answer is to stop running a workload it wasn't designed for on vanilla storage primitives. What you actually need is automatic time-based partitioning, hybrid row/columnar storage for analytical scans, and lifecycle management that doesn't require you to babysit it. You need Postgres extended for this workload, not replaced.</p><p>That's what TimescaleDB does. Same database, same SQL, same toolchain your team already knows. Different storage engine underneath.</p><p><em>If you want to go deeper on why vanilla Postgres hits this wall and what the underlying mechanics look like, </em><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><em><u>Understanding Postgres Performance Limits for Analytics on Live Data</u></em></a><em> covers the architecture side in detail. And if some of these signs felt familiar but you're not sure you're fully in the architectural category yet, </em><a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"><em><u>Six Signs That Postgres Tuning Won't Fix Your Performance Problems</u></em></a><em> has a different angle on the same diagnostic.</em></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Autovacuum: The Tax You're Always Paying]]></title>
            <description><![CDATA[Your append-only table doesn't need autovacuum. It runs anyway. Here's what that actually costs your team.]]></description>
            <link>https://www.tigerdata.com/blog/the-autovacuum-tax</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/the-autovacuum-tax</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Tue, 05 May 2026 19:04:21 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/05/Autovacuum_-The-Tax-You-re-Always-Paying-V2.png">
            </media:content>
            <content:encoded><![CDATA[<p>You open <code>pg_stat_activity</code> during a write peak and there it is. Autovacuum. Again.</p><p>You've tuned this three times. You wrote a runbook for it. At this point it has its own section in the quarterly database review.</p><p>The table it's working on hasn't seen a single UPDATE in six months. Every write is an INSERT. Old data gets dropped by partition, not deleted row by row. By every reasonable intuition about what autovacuum is for, this table should basically run itself.</p><p>But there it is.</p><p>The intuition isn't wrong. It's incomplete. Autovacuum exists to clean up after concurrent row modifications, and your workload doesn't do that. What your workload <em>does</em> generate is a steady stream of other work that lands in the same process: dead tuples from aborted transactions, hint bits that need setting, transaction IDs that need freezing before the counter wraps. None of it is the problem autovacuum was designed to solve. All of it runs through the same mechanism.</p><p>This post isn't about how to tune autovacuum. It's about understanding what it's actually doing on your tables, why tuning helps at the margin but not at the root, and what it means that a process built for row modification cleanup is your most persistent background worker on a table that never modifies rows.</p><h2 id="what-autovacuum-is-actually-for">What autovacuum is actually for</h2><p>Postgres MVCC keeps old row versions alive as long as any active transaction might need to see them. When a row gets updated, the old version stays on the heap page, marked dead. When a row gets deleted, same thing. These dead tuples accumulate until something cleans them up. That something is autovacuum.</p><p>Without it, dead tuples pile up permanently. <a href="https://www.tigerdata.com/learn/how-to-reduce-bloat-in-large-postgresql-tables"><u>Table bloat</u></a> grows without bound. Heap pages that hold dead tuples can't be reused. Query performance degrades as scans trip over dead rows.</p><p>And then there's the harder problem: <a href="https://www.tigerdata.com/blog/how-to-fix-transaction-id-wraparound"><u>XID wraparound</u></a>. Transaction IDs are 32-bit counters. About 2 billion transactions in, Postgres loses the ability to distinguish old from new. Rows from before the wraparound point become invisible. This isn't theoretical. It has happened in production. Autovacuum's freeze pass exists to prevent it by marking old tuples as frozen before the counter laps them.</p><p>For a standard OLTP workload with concurrent reads and updates on shared rows, this is essential infrastructure. Dead tuple accumulation is a direct consequence of normal operation. The cleanup cost is proportional to the update and delete rate. Makes sense.</p><p>The confusing part is what happens when your workload never updates or deletes anything.</p><h2 id="three-reasons-autovacuum-runs-on-append-only-tables">Three reasons autovacuum runs on append-only tables</h2><p>Each of these is a different mechanism. None of them are the same problem.</p><p><strong>Aborted transactions leave dead tuples.</strong> Not every INSERT commits. Connection drops mid-transaction. Application errors trigger rollbacks. Explicit transaction management has bugs. At <a href="https://www.tigerdata.com/blog/13-tips-to-improve-postgresql-insert-performance"><u>high insert rates</u></a>, even a small abort rate produces a steady trickle of dead tuples. A 0.1% abort rate at 50,000 inserts per second is 50 dead tuples per second. Autovacuum has to find and mark them, even though those rows were never part of a committed write.</p><p>This is real dead tuple work. Just not from updates. You can see it directly in <code>n_dead_tup</code> in <code>pg_stat_user_tables</code>. Tuning autovacuum to run more aggressively cleans it up faster. There's no way to eliminate it without eliminating aborted transactions, which isn't realistic.</p><p><strong>Hint bits require page dirtying.</strong> This one surprises most people who haven't dug deep into Postgres internals. When a row is first read after being written, Postgres doesn't just hand you the data. It verifies the writing transaction committed. It checks <code>pg_xact</code>. Once confirmed, it sets a hint bit in <code>t_infomask</code> to cache that result so future reads don't have to hit <code>pg_xact</code> again.</p><p>Setting that hint bit modifies the tuple header. A modified tuple header dirties the page. A dirty page needs writing back to disk.</p><p>So: your append-only table with immutable rows is generating I/O from reads. Not writes. Reads. The rows don't change. The headers do. This affects checkpoint pressure and the overall I/O budget autovacuum has to compete for.</p><p><strong>Insert volume alone triggers autovacuum for freezing.</strong> Since PostgreSQL 13, <code>autovacuum_vacuum_insert_threshold</code> and <code>autovacuum_vacuum_insert_scale_factor</code> control a separate trigger: autovacuum fires based on insert count, not just dead tuple count. The reason is XID wraparound prevention. At high insert rates, unfrozen tuples accumulate fast. Postgres needs to freeze them before the counter laps. So autovacuum runs a freeze pass continuously on high-insert tables, regardless of whether any rows have been updated or deleted.</p><p>Go check <code>vacuum_count</code> and <code>autovacuum_count</code> in <code>pg_stat_user_tables</code> on your busiest append-only partition. They're climbing. <code>n_dead_tup</code> might be low. The freeze passes are happening anyway. <a href="https://www.tigerdata.com/blog/using-bpftrace-to-trace-postgresql-vacuum-operations"><u>This BPFtrace walkthrough</u></a> shows exactly which passes and when.</p><h2 id="what-tuning-actually-does">What tuning actually does</h2><p>Most autovacuum tuning falls into two categories: make it run more aggressively, or make it yield more to writes.</p><p>Running it more aggressively means lowering <code>autovacuum_vacuum_scale_factor</code>, increasing <code>autovacuum_max_workers</code>, reducing <code>autovacuum_naptime</code>. Dead tuples get cleaned before they affect query plans. Freeze passes complete before XID pressure builds. Real improvement.</p><p>Yielding more to writes means increasing <code>autovacuum_vacuum_cost_delay</code>, lowering <code>autovacuum_vacuum_cost_limit</code>. Write latency stabilizes. The tradeoff is that vacuum falls further behind and bloat accumulates more between cycles.</p><p>Here's what neither category does: reduce the amount of work autovacuum needs to do. Every configuration choice is about how the work gets distributed across time and how aggressively it competes with your actual workload. You're tuning the scheduler. Not the workload.</p><p>Per-table overrides are the right implementation of this: more aggressive settings on active partitions, letting older ones vacuum on a slower cycle. Good practice. It's still adjusting the tax rate, not the taxable activity.</p><h2 id="the-operational-cost-people-dont-account-for">The operational cost people don't account for</h2><p>Autovacuum tuning isn't free engineering time.</p><p>Someone has to write the per-table <code>ALTER</code> statements. Someone has to monitor whether the settings are actually working. At 500 partitions, "monitor autovacuum lag" is a real job that runs on a recurring schedule. New partitions inherit defaults unless automation creates them with the right settings. That automation needs maintaining.</p><p>The monitoring surface for autovacuum lag spans three separate system views and requires correlating timestamps across them. It’s not a dashboard that lights up. It’s a debugging session.</p><p>When autovacuum falls behind, the symptom usually isn't an autovacuum alert. It's query latency regression. Write performance degradation. The connection between autovacuum backlog and query performance is real but indirect. Connecting the two is senior engineer work. Hours, not a quick look at a graph. Sigh.</p><p>If you've tracked where your team actually spends time on database operations, a meaningful slice of it is this: watching autovacuum, tuning autovacuum, debugging incidents that turn out to be autovacuum-adjacent, and writing runbooks for autovacuum behavior on new partition types.</p><p>All of that cost is real. None of it is the kind of cost an append-only workload should be paying.</p><h2 id="why-append-only-data-shouldnt-work-this-way">Why append-only data shouldn't work this way</h2><p>An append-only system has a simple storage contract. Data arrives. It gets written. It ages. Eventually it gets dropped, wholesale, by time window.</p><p>Nothing is ever modified in place. There is no concurrent row modification to manage. Reads never block writes and writes never block reads because there's no contention to prevent. The MVCC guarantee is irrelevant to the workload.</p><p>Postgres MVCC is the right model for workloads that need that concurrency guarantee. For workloads that don't, it's overhead. Autovacuum running continuously on your append-only table isn't Postgres failing. It's Postgres correctly maintaining the MVCC invariants on a workload that doesn't benefit from them.</p><p>The cost is real. The benefit isn't.</p><p>Tuning autovacuum makes the cost more manageable. It doesn't make the benefit appear.</p><h2 id="what-changes-when-the-storage-model-matches-the-workload">What changes when the storage model matches the workload</h2><p><a href="https://www.tigerdata.com/blog/hypercore-a-hybrid-row-storage-engine-for-real-time-analytics"><u>Hypercore</u></a> is built for exactly that contract. It abandons the per-tuple MVCC model for compressed column segments. Up to 1,000 row versions get batched into a single compressed segment before writing. Dead tuples in the traditional sense don't accumulate because the storage model doesn't create them.</p><p>When rows land in a Hypercore segment, transaction visibility is tracked at the segment level, not the tuple level. There is no per-tuple <code>t_xmin</code> to freeze. No hint bit to set on first read. The three mechanisms that drive autovacuum on your append-only heap table have nothing to work with here because the storage model does not create the conditions they require.</p><p>The result: autovacuum pressure drops proportionally. Postgres doesn't disappear. There's still housekeeping work. But the continuous autovacuum activity on high-insert partitions goes away. So does the I/O competition during write peaks, the tuning overhead, the monitoring surface for vacuum lag. The conditions that produced all of it no longer exist.</p><p><code>vacuum_count</code> stops climbing on tables that nobody updates. <code>pg_stat_activity</code> stops showing vacuum workers at 3am. The runbook section on autovacuum tuning gets shorter.</p><p>Same SQL. Same wire protocol. Different storage contract underneath, matched to the workload that's actually running.</p><h2 id="conclusion">Conclusion</h2><p>Autovacuum isn't broken. It isn't misconfigured. It's working correctly given what it's been handed.</p><p>The problem is that "working correctly" on an append-only high-insert table means running constantly, competing with writes, requiring ongoing tuning, and consuming real engineering time. All to manage overhead that a purpose-built append system wouldn't generate in the first place.</p><p>That's the tax. The settings determine how you pay it. The workload determines that you owe it.</p><p>If autovacuum is in your top processes by CPU and I/O on a table that nobody updates, that's not a Postgres problem to fix. It's a signal about the relationship between your workload and your storage model. If your write pattern is append-only, the question worth asking is not how to tune autovacuum better. It is whether your storage model is matched to your workload at all.</p><p><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>Understanding Postgres Performance Limits for Analytics on Live Data</u></a> goes deeper on where that mismatch shows up across the system, and what the path forward looks like at different data volumes.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Postgres Extensions Cheat Sheet: Replace 7 Databases With SQL]]></title>
            <description><![CDATA[Working SQL examples for replacing Elasticsearch, Pinecone, InfluxDB, Redis, and more, using Postgres extensions you can enable today.]]></description>
            <link>https://www.tigerdata.com/blog/postgres-extensions-cheat-sheet</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/postgres-extensions-cheat-sheet</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Extensions]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Sat, 02 May 2026 20:47:24 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/05/thumbnail-blog-thumbnail-1280x720--3-.png">
            </media:content>
            <content:encoded><![CDATA[<p>This post is a practical companion to <a href="https://www.tigerdata.com/blog/its-2026-just-use-postgres"><u>It's 2026, Just Use Postgres</u></a>. That post makes the architectural case for consolidating on Postgres. This one shows you how.</p><p>Below are working SQL examples for each use case. Every extension listed here is available on <a href="https://console.cloud.timescale.com"><u>Tiger Cloud</u></a> with no additional setup. If you're self-hosting, each section links to the extension's repo.</p><p><strong>What you'll be able to do after reading this:</strong> Set up Postgres extensions for full-text search, vector search, time-series, caching, message queues, document storage, geospatial queries, and scheduled jobs. Each section is self-contained, so you can skip to what you need.</p><h2 id="enable-everything">Enable Everything</h2><p>Here's the full set. You probably don't need all of them. Pick the ones that match your workload.</p><pre><code class="language-sql">CREATE EXTENSION pg_textsearch;   -- BM25 full-text search
CREATE EXTENSION vector;          -- Vector search (pgvector)
CREATE EXTENSION vectorscale;     -- DiskANN index for vectors
CREATE EXTENSION ai;              -- AI embeddings and RAG workflows
CREATE EXTENSION timescaledb;     -- Time-series
CREATE EXTENSION pgmq;            -- Message queues
CREATE EXTENSION pg_cron;         -- Scheduled jobs
CREATE EXTENSION postgis;         -- Geospatial</code></pre><h2 id="full-text-search-replace-elasticsearch">Full-Text Search (Replace Elasticsearch)</h2><p><strong>Extension:</strong> <a href="https://github.com/timescale/pg_textsearch"><u><code>pg_textsearch</code></u></a> (true BM25 ranking)</p><p><strong>What you're replacing:</strong> Elasticsearch (separate JVM cluster, complex mappings, sync pipelines), Solr, or Algolia ($1 per 1,000 searches).</p><p><strong>What you get:</strong> The same BM25 algorithm that powers Elasticsearch, running natively in Postgres. No separate cluster. No sync jobs. No data drift.</p><pre><code class="language-sql">CREATE TABLE articles (
  id SERIAL PRIMARY KEY,
  title TEXT,
  content TEXT
);

-- Create a BM25 index
CREATE INDEX idx_articles_bm25 ON articles USING bm25(content)
  WITH (text_config = 'english');

-- Search with BM25 scoring
SELECT title, -(content &lt;@&gt; 'database optimization') AS score
FROM articles
ORDER BY content &lt;@&gt; 'database optimization'
LIMIT 10;</code></pre><p><strong>Deep dive:</strong> <a href="https://www.tigerdata.com/blog/you-dont-need-elasticsearch-bm25-is-now-in-postgres"><u>You Don't Need Elasticsearch: BM25 is Now in Postgres</u></a></p><h2 id="vector-search-replace-pinecone">Vector Search (Replace Pinecone)</h2><p><strong>Extensions:</strong> <a href="https://github.com/pgvector/pgvector"><u><code>pgvector</code></u></a> + <a href="https://github.com/timescale/pgvectorscale"><u><code>pgvectorscale</code></u></a></p><p><strong>What you're replacing:</strong> Pinecone ($70/month minimum, separate infrastructure, data sync), Qdrant, Milvus, or Weaviate.</p><p><strong>What you get:</strong> pgvectorscale uses the DiskANN algorithm (from Microsoft Research). On a <a href="https://www.tigerdata.com/blog/pgvector-vs-pinecone"><u>50M vector benchmark</u></a>, it achieved 28x lower p95 latency and 16x higher throughput than Pinecone at 99% recall.</p><pre><code class="language-sql">CREATE EXTENSION vector;
CREATE EXTENSION vectorscale CASCADE;

CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT,
  embedding vector(1536)
);

-- High-performance DiskANN index
CREATE INDEX idx_docs_embedding ON documents USING diskann(embedding);

-- Find similar documents
SELECT content, embedding &lt;=&gt; '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding &lt;=&gt; '[0.1, 0.2, ...]'::vector
LIMIT 10;</code></pre><h3 id="auto-sync-embeddings-with-pgai">Auto-sync embeddings with pgai</h3><p>No more manual embedding pipelines. pgai regenerates embeddings automatically on every INSERT and UPDATE.</p><pre><code class="language-sql">SELECT ai.create_vectorizer(
  'documents'::regclass,
  loading =&gt; ai.loading_column(column_name =&gt; 'content'),
  embedding =&gt; ai.embedding_openai(
    model =&gt; 'text-embedding-3-small',
    dimensions =&gt; '1536'
  )
);</code></pre><p>Every row stays in sync. No batch jobs. No drift.</p><h2 id="hybrid-search-bm25-vectors-in-one-query">Hybrid Search: BM25 + Vectors in One Query</h2><p>This is where Postgres consolidation pays off immediately. Combining keyword search and semantic search in other stacks requires two API calls, result merging, failure handling, and double the latency. In Postgres, it's one query.</p><h3 id="simple-weighted-hybrid">Simple weighted hybrid</h3><pre><code class="language-sql">SELECT
  title,
  -(content &lt;@&gt; 'database optimization') AS bm25_score,
  embedding &lt;=&gt; query_embedding AS vector_distance,
  0.7 * (-(content &lt;@&gt; 'database optimization')) +
  0.3 * (1 - (embedding &lt;=&gt; query_embedding)) AS hybrid_score
FROM articles
ORDER BY hybrid_score DESC
LIMIT 10;</code></pre><h3 id="reciprocal-rank-fusion-for-rag-applications">Reciprocal Rank Fusion (for RAG applications)</h3><pre><code class="language-sql">WITH bm25 AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY content &lt;@&gt; $1) AS rank
  FROM documents LIMIT 20
),
vectors AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY embedding &lt;=&gt; $2) AS rank
  FROM documents LIMIT 20
)
SELECT d.*,
  1.0 / (60 + COALESCE(b.rank, 1000)) +
  1.0 / (60 + COALESCE(v.rank, 1000)) AS score
FROM documents d
LEFT JOIN bm25 b ON d.id = b.id
LEFT JOIN vectors v ON d.id = v.id
WHERE b.id IS NOT NULL OR v.id IS NOT NULL
ORDER BY score DESC LIMIT 10;</code></pre><p>One query. One transaction. One result set.</p><h2 id="time-series-replace-influxdb">Time-Series (Replace InfluxDB)</h2><p><strong>Extension:</strong> <a href="https://github.com/timescale/timescaledb"><u>TimescaleDB</u></a> (21K+ GitHub stars)</p><p><strong>What you're replacing:</strong> InfluxDB (separate database, Flux or limited SQL), Prometheus (metrics only, not application data).</p><p><strong>What you get:</strong> Automatic time-based partitioning, compression up to 95%, continuous aggregates for fast dashboards, and full SQL. Your time-series data lives alongside your relational data with <code>JOIN</code>s and <a href="https://www.tigerdata.com/learn/understanding-acid-compliance"><u>ACID guarantees</u></a>.</p><pre><code class="language-sql">CREATE EXTENSION timescaledb;

CREATE TABLE metrics (
  time TIMESTAMPTZ NOT NULL,
  device_id TEXT,
  temperature DOUBLE PRECISION
);

-- Convert to a hypertable (automatic time partitioning)
SELECT create_hypertable('metrics', 'time');

-- Query with time buckets
SELECT time_bucket('1 hour', time) AS hour,
       AVG(temperature)
FROM metrics
WHERE time &gt; NOW() - INTERVAL '24 hours'
GROUP BY hour;</code></pre><h3 id="lifecycle-automation">Lifecycle automation</h3><p>TimescaleDB handles retention and compression policies so you don't have to build cron jobs for data management.</p><pre><code class="language-sql">-- Automatically drop data older than 30 days
SELECT add_retention_policy('metrics', INTERVAL '30 days');

-- Compress data older than 7 days (up to 95% storage reduction)
ALTER TABLE metrics SET (timescaledb.compress);
SELECT add_compression_policy('metrics', INTERVAL '7 days');</code></pre><p><strong>Case study:</strong> <a href="https://www.tigerdata.com/blog/from-4-databases-to-1-how-plexigrid-replaced-influxdb-got-350x-faster-queries-tiger-data"><u>Plexigrid went from 4 databases to 1</u></a> and got 350x faster queries.</p><hr><h2 id="caching-replace-redis">Caching (Replace Redis)</h2><p><strong>Feature:</strong> <code>UNLOGGED</code> tables + <code>JSONB</code> (built into Postgres, no extension needed)</p><p><strong>What you're replacing:</strong> Redis for simple key-value caching scenarios.</p><p><strong>What you get:</strong> In-memory-speed storage without WAL overhead. Good for session data, temporary lookups, and simple caches. No separate service to operate.</p><p><strong>When to keep Redis:</strong> If you need pub/sub, sorted sets, Lua scripting, or complex data structures, Redis is still the better tool for those specific jobs.</p><pre><code class="language-sql">-- UNLOGGED = no WAL overhead, faster writes
CREATE UNLOGGED TABLE cache (
  key TEXT PRIMARY KEY,
  value JSONB,
  expires_at TIMESTAMPTZ
);

-- Set with expiration
INSERT INTO cache (key, value, expires_at)
VALUES ('user:123', '{"name": "Alice"}', NOW() + INTERVAL '1 hour')
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;

-- Get
SELECT value FROM cache
WHERE key = 'user:123' AND expires_at &gt; NOW();

-- Schedule cleanup with pg_cron
SELECT cron.schedule('cache_cleanup', '0 * * * *',
  $$DELETE FROM cache WHERE expires_at &lt; NOW()$$);</code></pre><h2 id="message-queues-replace-kafka">Message Queues (Replace Kafka)</h2><p><strong>Extension:</strong> <a href="https://github.com/tembo-io/pgmq"><u><code>pgmq</code></u></a></p><p><strong>What you're replacing:</strong> Kafka or RabbitMQ for task queues and simple event processing.</p><p><strong>What you get:</strong> A lightweight message queue inside Postgres. Send, receive with visibility timeouts, and delete after processing. Transactional with the rest of your data.</p><p><strong>When to keep Kafka:</strong> If you need high-throughput event streaming across dozens of services, consumer groups, exactly-once semantics, or multi-datacenter replication, Kafka is purpose-built for that.</p><pre><code class="language-sql">CREATE EXTENSION pgmq;
SELECT pgmq.create('my_queue');

-- Send a message
SELECT pgmq.send('my_queue', '{"event": "signup", "user_id": 123}');

-- Receive (with 30-second visibility timeout)
SELECT * FROM pgmq.read('my_queue', 30, 5);

-- Delete after processing
SELECT pgmq.delete('my_queue', msg_id);</code></pre><h3 id="alternative-skip-locked-pattern-no-extension-needed">Alternative: SKIP LOCKED pattern (no extension needed)</h3><p>For simple job queues, Postgres has a built-in pattern using <code>FOR UPDATE SKIP LOCKED</code>:</p><pre><code class="language-sql">CREATE TABLE jobs (
  id SERIAL PRIMARY KEY,
  payload JSONB,
  status TEXT DEFAULT 'pending'
);

-- Worker claims a job atomically
UPDATE jobs SET status = 'processing'
WHERE id = (
  SELECT id FROM jobs WHERE status = 'pending'
  FOR UPDATE SKIP LOCKED LIMIT 1
) RETURNING *;</code></pre><h2 id="documents-replace-mongodb">Documents (Replace MongoDB)</h2><p><strong>Feature:</strong> Native <code>JSONB</code> (built into Postgres since 2014)</p><p><strong>What you're replacing:</strong> MongoDB for document storage.</p><p><strong>What you get:</strong> Schemaless document storage with GIN indexing, plus everything Postgres gives you: ACID transactions, relational <code>JOIN</code>s, and SQL. No separate database for your "document-shaped" data.</p><pre><code class="language-sql">CREATE TABLE users (
  id SERIAL PRIMARY KEY,
  data JSONB
);

-- Insert a nested document
INSERT INTO users (data) VALUES ('{
  "name": "Alice",
  "profile": {"bio": "Developer", "links": ["github.com/alice"]}
}');

-- Query nested fields
SELECT data-&gt;&gt;'name', data-&gt;'profile'-&gt;&gt;'bio'
FROM users
WHERE data-&gt;'profile'-&gt;&gt;'bio' LIKE '%Developer%';

-- Index specific JSON fields for fast lookups
CREATE INDEX idx_users_email ON users ((data-&gt;&gt;'email'));</code></pre><h2 id="geospatial-replace-specialized-gis">Geospatial (Replace Specialized GIS)</h2><p><strong>Extension:</strong> <a href="https://postgis.net/"><u>PostGIS</u></a> (the industry standard since 2001)</p><p><strong>What you're replacing:</strong> Nothing, really. PostGIS is what most specialized GIS tools are built on. It powers OpenStreetMap and has been in production for 24 years.</p><pre><code class="language-sql">CREATE EXTENSION postgis;

CREATE TABLE stores (
  id SERIAL PRIMARY KEY,
  name TEXT,
  location GEOGRAPHY(POINT, 4326)
);

-- Find stores within 5km
SELECT name,
  ST_Distance(location, ST_MakePoint(-122.4, 37.78)::geography) AS meters
FROM stores
WHERE ST_DWithin(location, ST_MakePoint(-122.4, 37.78)::geography, 5000);</code></pre><h2 id="scheduled-jobs-replace-external-cron">Scheduled Jobs (Replace External Cron)</h2><p><strong>Extension:</strong> <a href="https://github.com/citusdata/pg_cron"><u><code>pg_cron</code></u></a></p><p><strong>What you're replacing:</strong> External <code>cron</code> jobs, Kubernetes CronJobs, or Lambda scheduled triggers for database maintenance tasks.</p><p><strong>What you get:</strong> Cron scheduling inside Postgres. Useful for cache cleanup, materialized view refreshes, data retention, and periodic aggregation.</p><pre><code class="language-sql">CREATE EXTENSION pg_cron;

-- Run cache cleanup every hour
SELECT cron.schedule('cleanup', '0 * * * *',
  $$DELETE FROM cache WHERE expires_at &lt; NOW()$$);

-- Refresh a materialized view every night at 2 AM
SELECT cron.schedule('rollup', '0 2 * * *',
  $$REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats$$);</code></pre><h2 id="fuzzy-search-typo-tolerance">Fuzzy Search (Typo Tolerance)</h2><p><strong>Extension:</strong> <code>pg_trgm</code> (built into Postgres)</p><pre><code class="language-sql">CREATE EXTENSION pg_trgm;

CREATE INDEX idx_name_trgm ON products USING GIN (name gin_trgm_ops);

-- Finds "PostgreSQL" even when typed as "posgresql"
SELECT name FROM products
WHERE name % 'posgresql'
ORDER BY similarity(name, 'posgresql') DESC;</code></pre><h2 id="whats-next">What's Next</h2><p>If you want the architectural argument for why consolidating on Postgres matters (especially in the AI era), read <a href="about:blank"><u>It's 2026, Just Use Postgres</u></a>.</p><p>All of these extensions come pre-configured on <a href="https://console.cloud.timescale.com"><u>Tiger Cloud</u></a>. Create a free database and start building.</p><p><strong>Further reading:</strong></p><ul><li><a href="https://www.tigerdata.com/docs/use-timescale/latest/extensions/pg-textsearch"><u>pg_textsearch documentation</u></a></li><li><a href="https://github.com/timescale/pgvectorscale"><u>pgvectorscale on GitHub</u></a></li><li><a href="https://www.tigerdata.com/docs/"><u>TimescaleDB documentation</u></a></li><li><a href="https://github.com/tembo-io/pgmq"><u>pgmq on GitHub</u></a></li><li><a href="https://postgis.net/"><u>PostGIS</u></a></li><li><a href="https://www.tigerdata.com/blog/from-4-databases-to-1-how-plexigrid-replaced-influxdb-got-350x-faster-queries-tiger-data"><u>How Plexigrid replaced InfluxDB and got 350x faster queries</u></a></li></ul>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Optimization vs. Architecture: Knowing the Difference]]></title>
            <description><![CDATA[Optimization problems stay fixed. Architectural ones come back. A framework for knowing which you're dealing with before you've spent months on the wrong fix.]]></description>
            <link>https://www.tigerdata.com/blog/optimization-vs-architecture-knowing-the-difference</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/optimization-vs-architecture-knowing-the-difference</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 29 Apr 2026 02:21:18 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/04/thumbnail-blog.png">
            </media:content>
            <content:encoded><![CDATA[<p>There are two kinds of database performance problems.</p><p>The first kind responds to optimization. You add an index, queries speed up, the improvement holds. Done.</p><p>The second kind responds to optimization temporarily. You add an index, queries speed up, then slow down again as data grows. You partition the table, scans get faster, then the partition count becomes its own management burden. You upgrade the instance, headroom appears, then it's consumed within a quarter. You're back in the same meeting, staring at the same graphs trending in the same wrong direction.</p><p>Both feel identical in the moment. A slow query is a slow query. The difference shows up after the fix. Optimization problems stay fixed. Architectural problems come back. The difference isn't a matter of degree; it's structural. Operational problems respond to tuning. Architectural problems compound with every additional row you write.</p><p>This distinction matters most for a specific class of workload: high-volume, append-heavy data that gets queried analytically. Time-series telemetry. Financial tick data. Operational metrics. Any system where data accumulates continuously and queries shift from point lookups to scans and aggregates on live data. If that describes your system, this post gives you a framework for figuring out which kind of problem you're dealing with, before you've spent six months finding out the hard way.</p><h2 id="what-optimization-can-fix">What optimization can fix</h2><p>Optimization is genuinely powerful. Worth being clear about that before talking about its limits.</p><p>Configuration mismatches are real problems with real fixes. <code>shared_buffers</code> set too low. <code>work_mem</code> too small for hash joins. <code>effective_cache_size</code> not reflecting actual available memory. You fix these once, they stay fixed. These aren't minor tweaks.</p><p>Missing indexes are the same story. A query doing a sequential scan when an index scan would serve is O(n) when it could be O(log n). Add the right index, the improvement is permanent. The fix works at 1 million rows and at 100 million rows.</p><p>Inefficient queries, connection management, autovacuum tuning, PgBouncer config. All real, all fixable, all improvements that don't interact with data volume in ways that eventually undo your work.</p><p>The common thread: these problems don't compound with scale. A missing index is a missing index. You find it, you fix it, you move on.</p><h2 id="why-certain-workloads-hit-a-wall">Why certain workloads hit a wall</h2><p>Here's where it gets tricky.</p><p>Some problems look like optimization problems on the surface. You apply the standard tools. Performance improves. You close the ticket. Three months later, you're back.</p><p>That pattern is the signal.</p><p>The underlying issue isn't misconfiguration. It's a mismatch between what your architecture provides and what your workload actually requires. For high-volume, append-heavy, analytically-queried data, that mismatch runs three layers deep, and each layer is worse than the last.</p><p><strong>Layer one: you're reading data you'll never use.</strong> When your dominant query pattern is analytical scans and aggregations across time ranges, and your storage model packs all columns together in rows, no index strategy resolves that. Row storage reads the full row on every access. For transactional workloads, fine. For a query that needs to scan 50 million rows and pull two columns from each one, you're reading every other column on every single row, all the way through. That's not a configuration problem. That's a physics problem.</p><p><strong>Layer two: you're paying for features you never use.</strong> MVCC exists so concurrent reads and writes stay correct. Valuable for transactional data. But if you're inserting 50,000 rows per second of data that will never be updated, you're paying the full cost of that concurrency model on every insert. Each row carries 23 bytes of <a href="https://www.tigerdata.com/blog/mvcc-feature-youre-paying-for-but-not-using"><u>MVCC transaction metadata</u></a>. At 50K inserts per second, that's overhead on 4.3 billion rows per day that will never be modified. Autovacuum runs constantly, cleaning up dead tuples that were never created through updates. No configuration setting removes that structural cost. You're paying correctness guarantees on data that will never be written again.</p><p><strong>Layer three: the maintenance never stops growing.</strong> <a href="https://www.tigerdata.com/blog/preventing-silent-spiral-table-bloat"><u>Autovacuum</u></a>, ANALYZE, background statistics collection. As data volume grows, the time these tasks take grows with them. Tuning parameters adjusts priority, not necessity. The work still has to happen, and it will compete with your production workload in ways that compound indefinitely. The data keeps accumulating, and the maintenance scales with it.</p><p>Operational problems respond to tuning. Architectural problems compound with every additional row you write.</p><h2 id="the-recurrence-test">The recurrence test</h2><p>The question is whether you've already crossed that line. Here's how to tell.</p><p>Apply the standard fix for whatever symptom you're seeing. Better index. Config change. Query rewrite. Partitioning. Then watch what happens.</p><p>A fix that holds for a week is probably an operational problem. A fix that holds for a month could be either; watch for regression. A fix that holds for a quarter, and then the same metrics start climbing again: that's the architectural signal.</p><p>But the most reliable test isn't the persistence of any individual fix. It's the pattern across fixes. Over the last 12 months:</p><ul><li>Are you solving different problems each time, or variations of the same problem?</li><li>Are the fixes holding, or buying progressively less time?</li><li>Is the interval between optimization cycles getting shorter?</li></ul><p>Diverse symptoms, targeted fixes, improvements that hold = healthy optimization. You're in the right architecture.</p><p>Same symptoms recurring, each fix buying less runway than the last, the cycle accelerating = you've crossed the architectural boundary. The optimization is working. It's just fighting a battle it can't win.</p><p>If you answered yes to all three of those last questions, you already know the answer. The recurrence test just gives you the language to say it out loud in a meeting.</p><h2 id="the-cost-of-getting-this-wrong">The cost of getting this wrong</h2><p>Both failure modes are expensive. Worth saying plainly, because the bias in engineering is almost always toward optimization over migration.</p><p><strong>Treating an optimization problem as architectural:</strong> You migrate when tuning would have been sufficient. The cost is a disruptive migration project (two to eight weeks is realistic), team bandwidth redirected from product work to infrastructure, and the risk of introducing new complexity into a system that didn't need it. This mistake is less common because the default is to keep optimizing. Teams usually get pushed toward migration by external pressure, not internal conviction.</p><p><strong>Treating an architectural problem as optimization:</strong> This one compounds. You keep tuning when the architecture is the constraint. The cost is cumulative engineering time (teams working through this pattern typically lose months per year to ongoing optimization cycles), a growing operational burden, and a deferred migration that gets more expensive the longer you wait. At 10 million rows, migration takes days. At 500 million, weeks. At a billion plus, you're looking at months.</p><p>Sigh.</p><p>Every quarter you spend on optimizations that aren't sticking, you're paying twice: once for the engineering time, and once in the form of increased migration cost when you eventually get there. The diagnostic matters because catching it early changes the math significantly.</p><h2 id="what-the-right-architecture-actually-changes">What the right architecture actually changes</h2><p>If you ran the recurrence test and landed where the evidence points, here's what changes.</p><p>If your workload is high-volume, append-heavy, and primarily analytical, the architectural answer doesn't require leaving Postgres. It requires extending Postgres with primitives designed for that specific workload pattern.</p><p>TimescaleDB addresses each of the three failure modes directly.</p><p><strong>Layer one (reading data you'll never use): </strong><a href="https://www.tigerdata.com/blog/hypercore-a-hybrid-row-storage-engine-for-real-time-analytics"><strong><u>Hypercore</u></strong></a><strong>.</strong> TimescaleDB's hybrid row/columnar storage engine keeps recent data in row format for writes and automatically converts older data to columnar format for analytical scans. A query that needs to scan millions of rows and extract two columns reads only those columns, from compressed columnar chunks. You stop paying row-storage costs on data you'll only scan.</p><p><strong>Layer two (paying for features you never use): Hypertables and chunk exclusion.</strong> Hypertables partition your data automatically by time. Chunk exclusion lets the query planner skip entire time partitions without scanning them. Query latency stays bounded as data grows, not because you've tuned something but because the planner knows which chunks are irrelevant. Compressed chunks also dramatically reduce autovacuum load: there's nothing to vacuum in a chunk where data is already frozen and compressed.</p><p><strong>Analytical performance on top of all of it: </strong><a href="https://www.tigerdata.com/blog/how-we-made-real-time-data-aggregation-in-postgres-faster-by-50-000"><strong><u>Continuous aggregates</u></strong></a><strong>.</strong> Incremental materialized views that refresh in the background, updating only what changed since the last refresh. Dashboards and aggregations stay fast without batch jobs or stale results. Precomputed rollups combine with raw recent data at query time, so results are current without the full scan cost.</p><p>The optimization work that was recurring becomes unnecessary because the architecture handles it at the right layer. You're not doing the same work in a faster system. You're doing less work in a system built for the load.</p><p>And it's still Postgres. Same SQL. Same extensions. Same tooling your team already knows. What changes is what happens underneath. That's the only thing that needed to change.</p><h2 id="the-decision">The decision</h2><p>Optimization and architecture solve different categories of problems. The skill is knowing which category you're in before you've invested months in the wrong one.</p><p>If your optimization work is targeted, diverse, and the fixes hold, you're in the right architecture. Keep going. The standard playbook works.</p><p>If the recurrence test comes back positive (same symptoms, shrinking runway, accelerating cycles), the architecture is the constraint. One more optimization buys a quarter. The right architectural change buys years.</p><p>That gap is the decision.</p><p>For the full diagnostic on what this workload pattern looks like in a real system, <a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"><u>Six Signs That Postgres Tuning Won't Fix Your Performance Problems</u></a> walks through six specific characteristics with concrete examples. For the deeper mechanical explanation of why vanilla Postgres hits these limits at the architecture level, <a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>Understanding Postgres Performance Limits for Analytics on Live Data</u></a> covers that ground. Start with whichever question is more pressing.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[The Best Time to Migrate Was at 10M Rows. The Second Best Time Is Now.]]></title>
            <description><![CDATA[Migration cost scales with data volume. The optimization tax you pay while waiting scales faster.]]></description>
            <link>https://www.tigerdata.com/blog/when-to-migrate-postgres-to-timescaledb</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/when-to-migrate-postgres-to-timescaledb</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 08 Apr 2026 17:45:12 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/04/Best-time-to-migrate-Blog.png">
            </media:content>
            <content:encoded><![CDATA[<p>There's a pattern that plays out across almost every team running high-volume append workloads on vanilla Postgres. I've watched it happen enough times that I can practically set a timer.</p><p>At 10M rows, everything is fine. Queries are fast. The team is shipping features. Nobody is thinking about the database.</p><p>At 50M, queries start getting slow. Someone opens a Jira ticket about dashboard latency. The fix is usually an index or two. Takes an afternoon.</p><p>At 100M, someone proposes partitioning. Or read replicas. Or bumping the instance size. These are reasonable ideas. They work for a while.</p><p>At 500M, the team is spending one to two days per sprint on database performance work. Not building product. Tuning <code>autovacuum_vacuum_cost_delay</code> and rewriting queries and having meetings about whether to re-partition.</p><p>At every stage, migration to a purpose-built solution feels like it can wait. The current optimizations are working. The pain is manageable. Next quarter has fewer deadlines. (Next quarter never has fewer deadlines.)</p><p>Then the table hits a billion rows, and migration is now a project, not a task. What would have been a weekend of <code>CREATE TABLE ... USING hypertable</code> and a data backfill is now a phased migration plan with rollback strategies and a project manager.</p><p>This post makes the case for migrating earlier than feels necessary. Not because your current setup is broken, but because the cost of waiting (what I'll call the optimization tax) compounds in ways that aren't visible until you're deep in them.</p><h2 id="the-migration-cost-curve">The migration cost curve</h2><p>Let's put real numbers on this. The technical steps of a migration don't change much as data grows. What changes is how long they take and how many people you need in the room.</p><p><strong>At 10M rows:</strong> Migration is essentially <code>CREATE TABLE</code>, convert to hypertable, backfill data. A single engineer, a weekend. Downtime measured in minutes if you use a blue-green approach. Risk is near zero because you can run both instances in parallel and compare results before cutting over.</p><pre><code class="language-sql">-- This is the scary migration at 10M rows
SELECT create_hypertable('sensor_data', 'time');

INSERT INTO sensor_data_new SELECT * FROM sensor_data_old;
-- Go get coffee. You'll be done before it's cool enough to drink.
</code></pre><p><strong>At 100M rows:</strong> Backfill takes hours, not minutes. You need to plan for write continuity during migration. Indexes need rebuilding. Compression policies need configuring. Testing is more involved because edge cases in the data are now visible. One engineer, one to two weeks.</p><p><strong>At 500M+ rows:</strong> Full migration project. Data transfer takes days. You need a parallel write strategy (dual-write or CDC). Query compatibility testing across the application layer. A rollback plan. Stakeholder communication. Two to four engineers, four to eight weeks.</p><p>The work itself doesn't get harder. There's just more of it, and the blast radius of a mistake gets larger. The migration at 10M rows and the migration at 500M rows are the same technical steps. The difference is entirely in scale and coordination overhead.</p><p>That's worth repeating: you're not doing something different at 500M rows. You're doing the same thing, slower, with more people watching.</p><h2 id="the-optimization-tax-the-part-people-dont-calculate">The optimization tax (the part people don't calculate)</h2><p>Migration has a visible cost. You can put it on a roadmap. Estimate it. Schedule it. Argue about it in sprint planning.</p><p>Staying has an invisible cost. I call it the optimization tax: the cumulative engineering time, infrastructure spend, and incident burden you pay every month to keep vanilla Postgres performing on a workload it wasn't designed for. And unlike migration, the optimization tax never stops. It just goes up.</p><p><strong>Engineering time on optimization.</strong> How many hours per sprint does your team spend on query tuning, partition management, autovacuum configuration, index strategy reviews? Track it for a month. I've seen teams burning 15-20% of their engineering capacity on database maintenance work that wouldn't exist on a purpose-built system. Most of them didn't realize it until they measured. (If you're wondering whether your team has <a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"><u>signs that tuning won't fix the problem</u></a>, I wrote about that too.)</p><p><strong>Instance cost escalation.</strong> The progression from <code>db.r6g.xlarge</code> to <code>db.r6g.4xlarge</code> to <code>db.r6g.8xlarge</code> happens gradually enough that nobody raises a flag. Each upgrade is individually justified. "We need more memory for the working set." "The CPU is pegged during dashboard queries." "Read replicas need a bigger instance too." The aggregate cost curve is something else entirely. I wrote a whole post about <a href="https://www.tigerdata.com/blog/vertical-scaling-buying-time-you-cant-afford"><u>why vertical scaling buys time you can't afford</u></a>. The short version: each instance upgrade gets you less headroom than the last.</p><p><strong>Opportunity cost.</strong> Every sprint hour spent on database maintenance is a sprint hour not spent on product features. This compounds. The team spending 15% of engineering time on database operations ships 15% fewer features than the team that doesn't. Over 12 months, that gap is visible to customers.</p><p><strong>Incident burden.</strong> Slow query alerts at 2 AM. Autovacuum blocking production writes. Replication lag during write spikes. These aren't catastrophic. They're erosive. They train the team to accept degraded baseline performance as normal. "Oh, the dashboard is slow on Mondays because of the weekly aggregation job." That sentence should make you uncomfortable.</p><p>Add these up over 12 months. That's your optimization tax. Compare it to the migration cost at your current data volume. The math almost always favors migrating now over migrating later. And the longer you wait, the more lopsided that comparison gets.</p><h2 id="why-teams-delay-and-why-those-reasons-stop-holding">Why teams delay (and why those reasons stop holding)</h2><p>I want to be fair about this. The reasons teams wait are legitimate. I've used most of them myself. But each one has a shelf life.</p><p><strong>"We don't have time right now."</strong> This is the most common one, and it contains a cruel irony. Migration time increases with data volume. Delaying to find a better window means the work itself gets bigger. The window never gets better. The task gets worse. The team that "doesn't have time" for a weekend migration at 10M rows will somehow need to find time for an eight-week migration at 500M rows.</p><p><strong>"The current optimizations are working."</strong> They're working now. Each optimization has a ceiling. When you hit it, you need the next one. The sequence is predictable: indexes, then partitioning, then read replicas, then instance upgrades, then custom vacuum tuning. Each step buys less time than the last. You're running up a down escalator.</p><p><strong>"Migration is risky."</strong> This is the one that sounds the most reasonable and holds up the least. Migration from Postgres to TimescaleDB is lower risk than most database migrations because TimescaleDB is Postgres. Same SQL. Same wire protocol. Same drivers. Same <code>pg_dump</code>. Your application code changes are minimal. The risk profile is closer to "adding an extension" than "switching databases." You're not leaving Postgres. You're giving it better tools.</p><pre><code class="language-sql">-- Your existing queries still work. This isn't a rewrite.
SELECT time_bucket('1 hour', ts) AS hour,
       avg(temperature),
       max(temperature)
FROM sensor_data
WHERE ts &gt; now() - interval '7 days'
GROUP BY hour
ORDER BY hour;
</code></pre><p><strong>"We'd need to convince stakeholders."</strong> Quantify your optimization tax. That's the whole pitch. "We stop spending X hours per month on database maintenance and reclaim that for product work." If your team is spending two days per sprint on database performance, that's roughly 20% of engineering capacity. Put a dollar figure on it. The conversation gets short.</p><h2 id="why-migration-risk-is-lower-than-you-think">Why migration risk is lower than you think</h2><p>This is the part where I'm supposed to list seven migration steps and make you feel calm about them. I'll skip the list and give you the summary instead: install the extension, create hypertables, backfill data, configure compression and retention, update connection strings, validate. The <a href="https://docs2.tigerdata.com/docs/migrate"><u>migration guide</u></a> walks through each one. The <a href="https://www.tigerdata.com/blog/postgresql-migration-made-easier"><u>live migration tool</u></a> handles the hard parts at scale.</p><p>The core point is this: the steps are the same whether you have 10M rows or 500M rows. What changes is the logistics around them. At 10M rows, the backfill is a single <code>INSERT...SELECT</code> that finishes while you're refilling your coffee. At 500M rows, it's a parallel <code>COPY</code> job that runs for days and needs monitoring, dual-write strategies, and a rollback plan.</p><p>The steps don't scale. The logistics do. And that's exactly why doing it earlier is the move.</p><h2 id="the-compound-benefit-of-migrating-early">The compound benefit of migrating early</h2><p>Everything above has been about the cost of staying. But there's a positive version of this story too.</p><p><strong>You skip the treadmill entirely.</strong> The team that migrates at 10M rows never learns what autovacuum tuning feels like at 500M rows. They never have the "should we add ClickHouse?" meeting. They never build the CDC pipeline. They never debug replication lag during write spikes. Those problems simply don't exist in their world. That's the real dividend.</p><p><strong>Compression from the start.</strong> TimescaleDB's native compression typically achieves 90-95% compression ratios on time-series data. One of our customers, Latitude, saves $12,000 per month on database costs from compression alone. Your storage costs grow at 5-10% of the raw data rate instead of 100%. Over 12 months on a high-ingest workload, that's a line item your finance team will notice.</p><p><strong>Fast dashboards without the custom plumbing.</strong> Continuous aggregates give you incrementally-updating materialized views that combine precomputed rollups with the newest raw data. FlightAware went from 6.4-second query times to 30 milliseconds. Your dashboards are fast from day one, not "fast after we spent two weeks building and maintaining custom materialized view refresh jobs." Hypertables handle partitioning automatically in the background, so you also never write another <code>CREATE TABLE sensor_data_2026_q2 PARTITION OF ...</code> DDL statement. One less thing.</p><p>That's the real cost of waiting. The migration effort is the obvious part. The intermediate suffering you accumulate between now and when you eventually migrate anyway? That's the part nobody budgets for.</p><h2 id="so-about-that-timing">So, about that timing</h2><p>The best time to migrate was when the table was small and the effort was trivial. If that window has passed, the second best time is now, before the data volume makes the migration larger and the optimization tax makes the ROI case embarrassingly obvious.</p><p>Every month of delay increases both costs: the migration gets bigger and the tax keeps compounding. The crossover point where "just optimize" costs more than "migrate and stop optimizing" is earlier than most teams think.</p><p>If you're reading this and nodding, you probably already know what you need to do. The question isn't whether to migrate. It's whether you do it this quarter while it's a task, or next year when it's a project.</p><p><a href="https://docs2.tigerdata.com/docs/migrate"><u>Get started with the migration guide →</u></a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Read Replicas Don't Solve Write Bottlenecks]]></title>
            <description><![CDATA[Read replicas fix read contention. They don't fix write throughput. Here's the mechanical reason why, and what actually changes the trajectory.]]></description>
            <link>https://www.tigerdata.com/blog/read-replicas-dont-solve-write-bottlenecks</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/read-replicas-dont-solve-write-bottlenecks</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Tue, 07 Apr 2026 16:15:23 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/04/Read-Replicas-Don-t-Solve-Write-Bottlenecks-V2.png">
            </media:content>
            <content:encoded><![CDATA[<p>You added a read replica and something real happened. Dashboard queries stopped competing with ingestion. The primary's CPU dropped. Write latency came down. You watched the metrics and thought: that worked.</p><p>It did work. For a while.</p><p>Now the replica is lagging during write peaks. The primary is accumulating WAL because the replica can't keep up. Write latency is climbing again, despite the replica handling every read you can throw at it. You're managing two Postgres instances instead of one, and the problem you solved is back.</p><p>Read replicas are a real solution to a real problem. You did the right thing. The right thing ran out.</p><p>What you have now is a write bottleneck. Read replicas solve a read bottleneck. These sound similar. The mechanics are completely different.</p><h2 id="what-read-replicas-actually-solve">What read replicas actually solve</h2><p>Let's be fair about this. The win is real.</p><p>Read replicas fix resource contention on the primary. When expensive analytical queries run on the same instance that handles ingestion, they compete directly for CPU, I/O, and buffer cache. A dashboard query scanning 200M rows is doing real work. It blocks autovacuum, slows the write path, and evicts hot data from shared buffers. Moving that query to a replica removes it from the primary entirely.</p><p>The immediate result: primary CPU drops, write latency improves, buffer cache stops getting thrashed by analytical scans. For a system where reads were the dominant load, this can be transformative. A SaaS application with thousands of concurrent users reading data that a small number of writers produce? Read replicas are the right call, and they solve the problem completely.</p><p>The mechanism is<a href="https://www.tigerdata.com/blog/scalable-postgresql-high-availability-read-scalability-streaming-replication-fb95023e2af"> <u>streaming replication</u></a>. WAL generated by the primary gets shipped to the replica and applied in order. The replica stays current. Reads on the replica are reads against real data.</p><p>That's the win. The primary has less to do because reads moved elsewhere. Full stop.</p><h2 id="what-read-replicas-dont-touch">What read replicas don't touch</h2><p>Here's where it gets mechanical. Each of these is specific, and none of them changes when you add a replica.</p><p><strong>The write path is identical.</strong> Every INSERT on the primary still goes through the full Postgres write path: heap tuple with<a href="https://www.tigerdata.com/blog/mvcc-feature-youre-paying-for-but-not-using"> <u>MVCC header</u></a>, B-tree index insertions for every index on the table, WAL record generation for heap and indexes, autovacuum triggered by insert volume for freezing. Nothing about streaming replication changes any of this. The replica receives the WAL and applies it. The WAL is generated at the same rate it always was, with the same per-row overhead.</p><p>Write amplification at 50K inserts/sec with five indexes is still 300K write operations per second on the primary. Adding a replica doesn't change that number. It adds the same write operations on the replica as well, applied from WAL.</p><p><strong>Autovacuum still runs on the primary.</strong> The maintenance work that drives continuous autovacuum activity on high-insert tables (hint bit setting, freeze passes, dead tuple cleanup from aborted transactions) still happens on the primary. Routing reads to a replica doesn't reduce the insert rate that triggers autovacuum. It doesn't reduce dead tuple accumulation from aborted transactions. It doesn't change the XID freeze schedule.</p><p>Autovacuum workers still show up in <code>pg_stat_activity</code> at 3am. They still compete with writes during peaks. The tuning conversation still happens every quarter.</p><p><strong>WAL volume increases.</strong> Here's the counterintuitive part. Adding replicas increases the total WAL-related workload, not decreases it. The primary now has to ship WAL to every replica in addition to writing it locally. At 50-100MB/sec sustained WAL generation, that's 50-100MB/sec of outbound data per replica. With two replicas, you're managing twice that outbound load.</p><p>This is usually invisible when replicas are keeping up. It becomes visible the moment a replica falls behind.</p><h2 id="the-replica-lag-problem">The replica lag problem</h2><p>This is where things get interesting. And by interesting, I mean expensive.</p><p>Streaming replication works by applying WAL on the replica fast enough to stay current. Under normal conditions this is fine. WAL apply is sequential and fast. Replicas keep up.</p><p>High sustained write volume changes this. At 50-100MB/sec of WAL generation, the replica is continuously applying writes. If the replica hits any slowdown (a large analytical query competing for I/O, a vacuum cycle, a momentary resource spike) it falls behind. A small lag at high WAL volume becomes a large lag fast.</p><p>Here's the self-reinforcing part, and it's the thing most people don't see coming.</p><p>A lagging replica can't be used for real-time dashboards. So reads start routing back to the primary. The primary is handling reads again. More resource contention on the primary. Write latency climbs. You're back where you started, except now you're also managing a lagging replica.</p><p>Meanwhile, the primary has to retain unprocessed WAL in <code>pg_wal</code> until the replica catches up. The further behind the replica falls, the more disk the primary uses holding WAL. On a system generating 100MB/sec of WAL, a replica that falls 30 minutes behind represents 180GB of retained WAL on the primary. That's not a theoretical edge case. That's a disk alert at 4am.</p><p><code>max_wal_size</code> and checkpoint frequency become critical tuning parameters that they weren't before. Another surface to monitor. Another thing to tune. Another runbook entry.</p><h2 id="the-operational-load-nobody-budgets-for">The operational load nobody budgets for</h2><p>Adding replicas means running multiple Postgres instances. The cost of that gets absorbed into engineering time, and it's rarely accounted for when the decision is made.</p><p>Each replica needs its own monitoring: replication lag alerts, vacuum state, connection pooling, failover procedures. <code>pg_stat_replication</code> on the primary becomes a permanent fixture in the ops dashboard. Lag thresholds need calibration. Alerts need tuning to avoid false positives during expected spikes.</p><p>Connection routing adds its own complexity. pgbouncer or pgpool sitting in front of the cluster, routing reads to replicas and writes to the primary. The routing layer needs its own monitoring, and it has its own failure modes. A misconfigured connection router that sends writes to a replica produces cryptic application errors that take a while to diagnose. That incident happens once on every team that adds replicas. (Ask me how I know.)</p><p>Schema migrations now touch multiple instances. An <code>ALTER TABLE</code> on the primary replicates to replicas, but the timing matters. Long-running schema changes that lock tables on the primary lock them on replicas too, which means reads route back to the primary, which defeats the purpose of having replicas in the first place.</p><p>New engineers need to understand the topology. Which instance handles writes. Which handles reads. What happens when a replica lags. When to promote a replica. The replica architecture that seemed like a contained infrastructure change has tendrils into application code, deployment procedures, and incident response.</p><p>None of this is unusual or unreasonable. It's the actual cost of the solution.</p><h2 id="isolation-vs-solution">Isolation vs. solution</h2><p>Step back from the mechanics for a second.</p><p>There are two different things that can happen when you add capacity to a struggling system. You can solve the problem, meaning the root cause goes away and performance improves permanently. Or you can isolate the problem, meaning you move it further from the things it was affecting. You buy time without changing trajectory.</p><p>Read replicas are isolation.</p><p>The write bottleneck on the primary doesn't go away. The autovacuum tax doesn't go away. The WAL volume doesn't decrease. What changes is that reads no longer compete with these things. The primary's problems are contained to the primary.</p><p>That's not nothing. Isolation buys real headroom, and it's the right call in the right situation. A system where reads actually were the bottleneck is solved, not just isolated. The problem class matters.</p><p>For<a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"> <u>continuous high-frequency ingestion</u></a>, the bottleneck isn't reads stealing write resources. It's write overhead at the storage level: MVCC headers on every tuple, B-tree index maintenance on every insert, WAL records per row per index, autovacuum competing for the same I/O budget. Routing reads elsewhere reduces resource contention on the primary but doesn't change the write overhead mechanics at all.</p><p>Six months after adding replicas, the primary is slower than it was when you added them. The data volume grew. The write overhead grew with it. The ceiling you isolated is now back in frame.</p><h2 id="what-actually-addresses-the-write-bottleneck">What actually addresses the write bottleneck</h2><p>The write bottleneck in this workload pattern is architectural. It's in the storage model: per-row MVCC overhead, per-insert WAL records, and the maintenance machinery built around row-level concurrency management. These are fixed costs baked into vanilla Postgres heap storage. They don't go away through replication topology changes.</p><p>What changes them is a different storage model.</p><p><a href="https://www.tigerdata.com/blog/hypercore-a-hybrid-row-storage-engine-for-real-time-analytics"><u>Columnar storage</u></a> batches 1,000 rows into compressed segments before writing. Instead of 1,000 individual heap inserts, each generating its own WAL record plus WAL records for every index entry, you get one segment write and one WAL record. The per-row MVCC headers that added 23 bytes to every tuple get amortized across the batch. Autovacuum pressure drops because the storage engine isn't creating row-level churn that needs cleaning up.</p><p>Do the math on WAL alone. At 100K inserts/sec in vanilla Postgres with five indexes, you're generating a WAL record for each heap tuple and each index insertion: roughly 600K WAL entries per second. Columnar batching at 1,000 rows per segment reduces that to ~600 segment-level WAL entries per second. WAL volume goes from 50-100MB/sec to roughly 5-15MB/sec. Replica lag stops being a crisis because replicas can apply WAL at the rate it arrives. The primary stops retaining large WAL backlogs. The monitoring surface shrinks. The replication architecture that was struggling to keep up suddenly has room to breathe.</p><p>The replicas don't go away. They just stop being the bottleneck.</p><h2 id="where-this-leaves-you">Where this leaves you</h2><p>Read replicas were the right call. The write performance improvement was real. The isolation of read traffic from ingestion was real. You did the correct thing given what you knew.</p><p>What it doesn't do is change the underlying write cost. That cost is in the storage model, and it scales with data volume. Adding replicas doesn't touch it. Tuning replication doesn't touch it. The ceiling that read replicas pushed into the background is still there, and it moves closer as ingestion continues.</p><p>The write bottleneck gets solved at the storage level or it doesn't get solved. Replication topology is orthogonal to that problem.</p><p>If continuous high-frequency ingestion describes what you're running and you're already managing replica lag,<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>the full picture of the optimization treadmill</u></a> covers what's left on the path and what the off-ramp looks like.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Document Databases: Be Honest]]></title>
            <description><![CDATA[Most MongoDB pain isn't a MongoDB problem. It's a workload shape problem that would follow you to Postgres.]]></description>
            <link>https://www.tigerdata.com/blog/document-databases-be-honest</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/document-databases-be-honest</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 01 Apr 2026 17:22:30 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/04/Document-Databases_-Be-Honest-V2.png">
            </media:content>
            <content:encoded><![CDATA[<p>MongoDB gets a bad reputation in certain engineering circles that it doesn't entirely deserve. It ships fast. Schema flexibility is real. The developer experience for document-shaped data is good. A lot of teams made a reasonable call when they chose it.</p><p>But there's a version of this story that ends badly, and it follows a recognizable pattern. The team picks MongoDB for a new system. The system works. Then the data starts looking less like documents and more like a stream of timestamped events. Queries start filtering by time range. Write volume climbs. Performance degrades in ways that feel familiar if you've read about this problem, and deeply confusing if you haven't.</p><p>This post isn't here to relitigate the MongoDB decision. It's here to help you figure out whether the pain you're feeling is a MongoDB problem, a document database problem, or a workload problem that would follow you to Postgres.</p><p>The answer matters because the fix is different in each case.</p><h2 id="what-mongodb-is-actually-good-at">What MongoDB is actually good at</h2><p>Flexible schema for variable data that's actually variable. Product catalogs where every SKU has different attributes. User profiles where fields vary by account type. Content management where article structure differs by category. These are real document shapes, and MongoDB handles them without the ceremony Postgres requires.</p><p>Rapid iteration without migration overhead. Early-stage products change their data model constantly. In Postgres, every schema change is an <code>ALTER TABLE</code>. In MongoDB, you just write different fields. For teams that are still figuring out the shape of their data, this is a real advantage.</p><p>Nested and hierarchical data. Some data is naturally a tree. A purchase order with line items with sub-components. A configuration object with nested sections. Postgres can model this with JSONB, but MongoDB's native document model fits it more naturally and queries it more cleanly.</p><p>Horizontal scaling for document reads. MongoDB's sharding model was designed for document workloads. For read-heavy document access at scale, it's a mature and well-understood architecture.</p><p>These aren't consolation prizes. They're real reasons MongoDB is the right choice for a lot of workloads.</p><p>The trouble starts when the data changes shape.</p><h2 id="what-time-series-data-actually-looks-like">What time-series data actually looks like</h2><p>Time-series data has a specific shape, and it's not a document shape. Every row is a measurement. It has a timestamp, a source identifier, and a value or set of values. The schema doesn't vary between rows. There's nothing hierarchical about it. The document model isn't adding anything.</p><p>What time-series data has instead: enormous volume, strict ordering requirements, queries that almost always filter by time range, and retention policies that drop entire time windows at once.</p><p>A wind turbine sensor reporting every five seconds doesn't produce documents. It produces a flat stream of readings: timestamp, sensor ID, RPM, temperature, vibration. A financial trade feed isn't a document store. It's a sequence of immutable events. An APM platform collecting metrics from a distributed system is generating hundreds of thousands of measurements per second, all with the same shape.</p><p>The test is simple. Look at your most-written collection. Does each document have a different structure? Or does every document look essentially the same, with a timestamp and some measurements?</p><p>If it's the latter, you're storing time-series data in a document database, and the document model is providing zero value while the storage engine works against you.</p><h2 id="where-mongodb-struggles-with-this-workload">Where MongoDB struggles with this workload</h2><p>WiredTiger (MongoDB's default storage engine) uses a B-tree structure optimized for a workload that includes updates to existing documents. For high-frequency append-only writes, it faces a fundamental mismatch. Consider a single sensor reading: one document insert triggers a write to the primary collection, a write to the oplog, and a separate B-tree update for every index on that collection. Three indexes means five writes for one data point. At 10,000 inserts per second, that's 50,000 storage operations per second before you've run a single query. The engine was designed for mixed read-write workloads with in-place updates, not an endless append stream where no document is ever modified after creation.</p><p>MongoDB has no native time-based partitioning. Postgres has declarative range partitioning.<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>TimescaleDB automates it entirely with hypertables</u></a>. MongoDB has no equivalent primitive. Teams end up implementing time-based collection bucketing manually: separate collections per day or week, application-level routing logic, custom cleanup scripts. It works, but it's the same operational burden as manual Postgres partitioning, without the tooling ecosystem that exists on the Postgres side.</p><p>MongoDB's aggregation pipeline is expressive. But for time-series workloads, the queries that matter are time-range aggregations: hourly averages, daily maximums, week-over-week comparisons. These queries scan large volumes of documents and aggregate across fields. Without columnar storage and purpose-built time-series compression, performance degrades with data volume in the same way it does in vanilla Postgres.</p><p>MongoDB did add a native time-series collection type in 5.0. It's a real improvement for simple append-only use cases. But it doesn't support secondary indexes the same way regular collections do, restricts certain aggregation stages and update operations, and is still relatively new compared to the Postgres ecosystem. Worth knowing about. Not a full answer.</p><h2 id="why-moving-to-vanilla-postgres-isnt-automatically-the-fix">Why moving to vanilla Postgres isn't automatically the fix</h2><p>This is the section most competitive content skips entirely. If you're evaluating a migration, you deserve the full picture.</p><p>If the workload is continuous high-frequency time-series ingestion with long retention and operational query requirements, vanilla Postgres has its own version of this problem.<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>The MVCC overhead, write amplification, autovacuum contention, and index maintenance costs that create the Optimization Treadmill</u></a> exist in Postgres too. The storage model is different from MongoDB's, but the outcome at scale is the same: performance degrades with data volume, maintenance overhead accumulates, and each optimization cycle buys time without changing the trajectory.</p><p>Moving from MongoDB to vanilla Postgres solves the schema flexibility problem (you probably don't need it for this workload anyway). You get a mature partitioning ecosystem, a better query planner, and a richer extension ecosystem. These are real improvements.</p><p>It doesn't solve the core time-series storage problem, because that problem lives in the storage model, not the database brand.</p><p>The question isn't MongoDB vs. Postgres. It's document store vs. purpose-built time-series storage. That's the actual axis the decision should sit on.</p><h2 id="the-decision-framework">The decision framework</h2><p><strong>Your data is actually documents.</strong> Variable schema, nested structures, hierarchical relationships, read-heavy access patterns. MongoDB is the right tool. The pain you're feeling is probably a schema design or indexing problem, not a fundamental architectural mismatch. Fix the schema.</p><p><strong>Your data is time-series but volume is modest.</strong> Sub-10K inserts per second, retention under 90 days, no hard operational latency requirements on the full retention window. Vanilla Postgres with good partitioning and indexing handles this fine. The Optimization Treadmill exists, but the ceiling is far enough away that standard tuning keeps you ahead of it. Move to Postgres, implement partitioning early, and<a href="https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems"> <u>monitor the warning signs</u></a>.</p><p><strong>Your data is time-series at sustained high volume.</strong> Continuous ingestion, long retention, operational query requirements, growing data volume. This is the workload that breaks both MongoDB and vanilla Postgres through the same class of mechanisms. Purpose-built time-series storage on Postgres (same SQL, same wire protocol, same tooling) is the right answer.<a href="https://www.tigerdata.com/learn/complete-guide-migrating-from-mongodb-to-tiger-data-step-by-step" rel="noreferrer"> <u>Migration from MongoDB to TimescaleDB follows a well-documented path</u></a>: you keep everything Postgres-compatible and gain the storage architecture that matches the workload.</p><h2 id="what-to-do-next">What to do next</h2><p>MongoDB didn't fail you if you're reading this. Your workload evolved past what document storage was designed for. That's a different thing.</p><p>Most database choices are right at the time they're made and wrong eighteen months later when the system looks nothing like it did at launch. Sensor data that started as a feature became the core product. The document store that handled early prototyping became the production system for a time-series pipeline.</p><p>The question now is whether the fix is tuning, migration, or architecture. The framework above gives you a clear read on which one applies. If it's architecture, the good news is that moving from MongoDB to a Postgres-compatible time-series database is less disruptive than it sounds. Your application SQL stays the same. Your tooling stays the same. The storage engine underneath is the thing that changes.</p><p>That's the right scope for the change. Not the whole stack. Just the part that was always wrong for this workload.</p><p><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>Read the full technical breakdown of why vanilla Postgres hits these limits</u></a>, or<a href="https://console.cloud.tigerdata.com/signup"> <u>start a Tiger Cloud trial</u></a> and see how TimescaleDB handles your workload directly.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Postgres Performance: Why Peak Throughput Benchmarks Miss the Real Problem]]></title>
            <description><![CDATA[Peak throughput tells you what Postgres can do in a sprint. Production asks what it can do forever. Those are different questions.]]></description>
            <link>https://www.tigerdata.com/blog/postgres-performance-why-peak-throughput-benchmarks-miss-real-problem</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/postgres-performance-why-peak-throughput-benchmarks-miss-real-problem</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Fri, 27 Mar 2026 14:30:33 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/03/The-Database-Question-Nobody-Asks.png">
            </media:content>
            <content:encoded><![CDATA[<p>You ran the benchmark. 80,000 inserts per second. The database handled it clean, latency stayed flat, no alarms. You shipped with confidence.</p><p>Three months later, p95 write latency is creeping. Six months later, autovacuum is in your top processes by CPU. Nine months later, you're rebuilding indexes on a table that's crossed 400 million rows.</p><p>The benchmark wasn't wrong. The question it answered just wasn't the right one.</p><p>Peak throughput tells you what the database can do in a sprint. Production asks what it can do running forever. Those are different questions with different answers, and most teams only ask the first one.</p><p>The number that actually matters is the <em>sustained throughput ceiling</em>: the write rate at which all of the database's maintenance processes (autovacuum, checkpointing, WAL archiving, replication) can keep up indefinitely. It's always lower than peak throughput. It drops over time as data volume grows. And almost nobody measures it.</p><h2 id="what-benchmarks-actually-measure">What benchmarks actually measure</h2><p>A typical load test runs for minutes. Sometimes an hour if you're thorough. It hits the database hard, measures throughput and latency, and stops. During that window, the buffer cache is warm from the test setup. Autovacuum hasn't had time to accumulate a backlog. WAL hasn't been generating for 72 hours straight. The indexes are fresh. The table fits mostly in memory.</p><p>These are ideal conditions. Not because anyone cheated. That's just what a bounded test looks like. The database performs brilliantly under bounded load because its maintenance subsystems haven't been outrun yet.</p><p>Production is unbounded. The data keeps arriving after the benchmark ends. Autovacuum runs against a table that grows every hour. The buffer cache works against a dataset that expands past RAM over weeks. The indexes that fit in memory at 50 million rows don't fit at 500 million. The checkpoint cycle that completed cleanly at low data volume starts competing with writes as WAL volume climbs.</p><h2 id="the-specific-ways-sustained-load-differs-from-peak-load">The specific ways sustained load differs from peak load</h2><p>There are four concrete mechanisms at work here. All four run simultaneously in production. None of them show up in a benchmark.</p><h3 id="your-hot-data-stops-being-hot">Your hot data stops being hot</h3><p>At launch, your hot data fits in <code>shared_buffers</code> and the OS page cache. Read performance is largely a RAM question. As data volume grows past available RAM, cache hit rates fall. Queries that returned in milliseconds start hitting disk. The degradation is slow enough that it looks like a query regression, not a growth problem, and that's what makes it dangerous. You'll spend a sprint chasing query plans and index strategies before someone checks <code>pg_statio_user_tables</code> and realizes the hit rate has been sliding since month four. The latency change wasn't a code problem. It was a ratio problem.</p><h3 id="autovacuum-falls-behind-and-cant-catch-up">Autovacuum falls behind and can't catch up</h3><p>A benchmark run doesn't give autovacuum time to fall behind. Production does.</p><p>At high sustained insert rates, autovacuum fires continuously. During write peaks, it falls behind. The backlog accumulates. Bloat builds. By the time monitoring catches it, the table has weeks of accumulated dead tuples and hint-bit work queued up.</p><p>Here's the part that really gets you: clearing the backlog requires running autovacuum harder, which competes with writes, which slows ingestion. The fix and the problem share the same resource pool. You're asking the database to clean up faster while also writing faster, and there's only so much I/O to go around.</p><h3 id="indexes-rot">Indexes rot</h3><p>Fresh B-tree indexes on a small table are compact and cache-friendly. The same indexes a year later on a table with a billion rows are fragmented, partially sparse from the hot-right-edge problem on timestamp columns, and too large to stay in cache.</p><p>Traversal costs go up. Page splits happen more often. The 10x read improvement you got from careful indexing in the first month erodes slowly, then faster. You'll REINDEX and get performance back for a while, but the table is still growing. The next degradation cycle is already in progress.</p><h3 id="wal-never-stops-arriving">WAL never stops arriving</h3><p>WAL volume scales directly with insert rate. At sustained high rates, WAL generation is constant. Replicas that keep up at launch start falling behind as write volume grows. The primary retains unprocessed WAL. Disk fills. And the replica needs to process a growing backlog while new WAL keeps arriving, which means there's no quiet period to catch up. If you've ever watched <code>pg_stat_replication</code> and seen <code>replay_lag</code> tick steadily upward with no sign of plateauing, you know exactly how this ends.</p><p>Each of these mechanisms is invisible in a benchmark. In production, they compound.</p><h2 id="the-number-you-should-actually-be-looking-at">The number you should actually be looking at</h2><p>So how do you actually find the sustained throughput ceiling?</p><p>You can estimate it. Look at autovacuum activity under current load: is it finishing cycles or perpetually falling behind? Check <code>pg_stat_bgwriter</code> for checkpoint pressure. Watch <code>pg_wal</code> directory size trends. Plot the ratio of index size to table size over time. These aren't exotic metrics. They're already in Postgres. Most teams aren't watching them together.</p><p>The leading indicators of a sustained throughput ceiling: autovacuum consistently showing in <code>pg_stat_activity</code>, checkpoint completion times trending up, replica lag growing during write peaks, <code>n_dead_tup</code> climbing faster than <code>vacuum_count</code> is cleaning.</p><p>None of these show up in a benchmark. All of them show up in production, usually together, usually around month six or nine.</p><figure class="kg-card kg-image-card"><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/03/data-src-image-0038c140-3769-49ad-abbc-4e1e65c072e1.jpeg" class="kg-image" alt="" loading="lazy" width="1376" height="768"></figure><h2 id="why-this-question-is-structurally-hard-to-ask">Why this question is structurally hard to ask</h2><p>Smart teams miss this. The reasons are structural.</p><p>Benchmarks have a natural stopping point. Load tests end. Sustained load doesn't have a natural evaluation moment until something breaks. There's no "sustained throughput benchmark" in most team playbooks because the concept doesn't have a clean boundary. When do you declare the test over?</p><p>The degradation timeline is also longer than most planning cycles. Indexing starts showing stress at 300 million rows. Partitioning gets complicated at 500+ partitions. WAL volume becomes a crisis when replica lag crosses a threshold that trips an alert. These events are six to eighteen months apart. The engineer who ran the initial benchmark often isn't the one debugging the production incident.</p><p>Then there's the procurement problem. Peak throughput is a good number for architecture decisions. "This database handles 80K inserts per second" is a clean, defensible statement. "This database handles 80K inserts per second now, but that number will effectively be lower in eight months as the buffer cache hit rate falls and autovacuum starts competing for I/O" is harder to put in a slide. (Both statements are true. Only one of them gets you budget approval.)</p><p>And most capacity planning frameworks are built around static estimates. How many users, how many requests, how much storage. Sustained throughput degradation is a dynamic problem. The ceiling moves as the system runs. That doesn't fit neatly into a capacity model built for stable workloads.</p><p>This adds up to something bigger than individual teams making mistakes. The entire way the industry evaluates databases is optimized for procurement, not production. Vendor benchmarks measure peak throughput because it's the largest number. Load testing frameworks default to bounded runs because unbounded runs don't have a natural end state. Capacity planning templates assume static ceilings because dynamic ceilings are harder to model. Every layer of the evaluation stack is designed to produce a number that looks good in a slide deck. None of it answers the question you'll actually need answered in month twelve.</p><p>So if the standard evaluation framework is structurally set up to miss this, what does a better one look like?</p><h2 id="what-the-right-benchmark-looks-like">What the right benchmark looks like</h2><p>Run the load test for longer. Hours, not minutes. Watch what happens to autovacuum, not just query latency.</p><p>Start the test with a table that already has data in it, sized to your 12-month projection. A benchmark on an empty table tells you about cold start performance. It tells you almost nothing about what the system looks like after a year of continuous ingestion.</p><p>Measure these things during the test:</p><ul><li><code>pg_stat_bgwriter</code>: checkpoint frequency and write volume</li><li><code>pg_stat_activity</code>: autovacuum activity</li><li>Replica lag if you're running replicas</li><li><code>pg_stat_wal</code>: WAL generation rate</li><li>Index size relative to table size</li></ul><p>Repeat the test with 3x the data volume. If performance drops more than linearly, you've found where the architecture starts to strain. That's the number you want before you ship, not after.</p><p>The test that catches the<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>Optimization Treadmill</u></a> is a test that asks: what happens when this runs for a year? You can simulate that in a day if you load the data upfront and run the benchmark against a realistic data volume.</p><h2 id="the-benchmark-question-and-the-architecture-question">The benchmark question and the architecture question</h2><p>If your system has<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>the six workload characteristics</u></a> (continuous ingestion, time-series access patterns, append-only data, long retention, operational query requirements, sustained growth), the sustained throughput ceiling is structural. Better benchmarking tells you earlier where the ceiling is, but it won't raise it.</p><p>Benchmarking tells you how fast the ceiling approaches. Architecture determines where it sits.</p><p>Teams that run good sustained-load benchmarks early find out at 30 million rows that they're on the Optimization Treadmill. Teams that only run peak throughput benchmarks find out at 800 million rows. The underlying architectural problem is identical in both cases. The migration cost is not.</p><h2 id="ask-the-right-question-before-you-ship">Ask the right question before you ship</h2><p>Peak throughput is a useful number. It tells you whether the hardware can keep up with the write rate at a point in time. Worth knowing.</p><p>It just doesn't tell you whether the maintenance processes can keep up with that write rate indefinitely, as data volume grows and the vacuum backlog and WAL volume and cache pressure all grow with it.</p><p>The question nobody asks before shipping is usually the one that generates the incident nine months later. Ask it now. Run the load test against a full-size dataset. Watch autovacuum, not just query latency. Track the ceiling as a moving target, not a static spec.</p><p>And if the benchmark reveals what the<a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"> <u>scoring framework</u></a> already suggested, the cheapest architectural decision you'll make is the one you make before the table crosses 100 million rows.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[MVCC: The Feature You're Paying For But Not Using]]></title>
            <description><![CDATA[MVCC is great for concurrent workloads. For append-only data, it's 23 bytes of overhead per row that never gets used. Here's what that actually costs.]]></description>
            <link>https://www.tigerdata.com/blog/mvcc-feature-youre-paying-for-but-not-using</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/mvcc-feature-youre-paying-for-but-not-using</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[Scaling PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Fri, 20 Mar 2026 13:07:26 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/03/V1.png">
            </media:content>
            <content:encoded><![CDATA[<p>Most engineers have a working mental model of MVCC. Readers don't block writers. Concurrent transactions see consistent snapshots. It's why Postgres handles mixed read/write workloads so well, and it's a genuine engineering achievement.</p><p>What's less obvious is that MVCC isn't free. Every row in every table carries its overhead. Not just rows that get updated. The system doesn't know at write time whether a row will ever be touched again, so it prepares for that possibility. Every time.</p><p>If you're running an IoT pipeline, a financial data feed, or an observability platform, most of your rows will never be updated. Sensor readings don't get corrected. Trade records are immutable. Log entries are permanent. You're writing append-only data into a system built to handle concurrent modification of shared rows, and you're paying the full price for that capability whether you use it or not.</p><p>This post breaks down exactly what that costs you: at the byte level, at the I/O level, and at the maintenance level.</p><h2 id="what-mvcc-actually-does-and-why-its-damn-good">What MVCC actually does (and why it's damn good)</h2><p>Before MVCC, databases had two options: lock rows during reads so writers couldn't touch them, or lock rows during writes so readers couldn't see them. Either way, concurrent workloads serialized through lock contention. If you've ever worked with a database that does this, you know how painful it gets at scale.</p><p>MVCC solves the problem differently. When a row is updated, Postgres doesn't modify it in place. It writes a new version of the row and keeps the old version visible to transactions that started before the update. Each transaction sees a consistent snapshot of the database as of the moment it began. Readers and writers operate on different row versions simultaneously. No locking required.</p><p>For an e-commerce backend processing orders while users browse, a SaaS application handling concurrent sessions, or any system where multiple transactions touch the same rows, this is transformative. The PostgreSQL documentation puts it simply: reading never blocks writing and writing never blocks reading.</p><p>That's not a small thing. That's the reason Postgres can handle the concurrency patterns that would bring a lock-based system to its knees.</p><p>The cost of maintaining this guarantee is what the rest of this post is about.</p><h2 id="the-per-row-overhead-in-bytes">The per-row overhead, in bytes</h2><p>This is where most explanations go vague. Let's not do that.</p><p>Every heap tuple in Postgres carries a fixed 23-byte header before a single byte of your actual data gets written. Here's what's in it:</p><ul><li><code>t_xmin</code>: the transaction ID that created this row (4 bytes)</li><li><code>t_xmax</code>: the transaction ID that deleted or updated it, zero if the row is live (4 bytes)</li><li><code>t_cid</code>: command ID within the transaction (4 bytes)</li><li><code>t_ctid</code>: physical location of this tuple or its newer version (6 bytes)</li><li><code>t_infomask</code> and <code>t_infomask2</code>: status flags for transaction visibility (4 bytes)</li><li><code>t_hoff</code>: offset to actual row data (1 byte)</li></ul><p>These fields exist to answer one question: is this row visible to this transaction?</p><p>For a workload where rows are being updated and deleted concurrently, that question needs answering constantly. The 23 bytes are worth it.</p><p>For an append-only workload? <code>t_xmax</code> is zero for every live row and will stay zero. <code>t_ctid</code> points to itself because there's no newer version. The visibility question still gets asked, and the header still gets written, and the page still gets dirtied to set hint bits after the first read. But the answers are trivial every time. The mechanism is running in full for a case that never needed it.</p><p>Add alignment padding and a 4-byte <code>ItemIdData</code> pointer per tuple, and the true per-row overhead is closer to 28 to 30 bytes before your row data starts.</p><p>Let's make that concrete. At 50K inserts per second, that's 1.4 to 1.5 MB/sec of pure overhead headers. Per year: roughly 44 GB of header data for a workload that never updates a row.</p><p>That's not a rounding error.</p><figure class="kg-card kg-image-card"><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/03/heap-tuple-diagram-v2.jpg" class="kg-image" alt="" loading="lazy" width="2752" height="1536"></figure><h2 id="what-autovacuum-is-actually-doing-on-your-append-only-table">What autovacuum is actually doing on your append-only table</h2><p>Here’s what’s going to wrinkle your brain.</p><p>You think,&nbsp; “Autovacuum cleans up dead tuples from updates and deletes. Append-only tables don't update or delete rows. Therefore, autovacuum shouldn't have much to do.”</p><p>That intuition is wrong in three specific ways.</p><p><strong>Aborted transactions leave dead tuples.</strong> Not every INSERT commits. Connection drops, application errors, explicit rollbacks. These all leave tuple versions that need cleaning. If you're running high insert rates, you've got a steady trickle of aborted transactions even in perfectly healthy systems.</p><p><strong>Hint bits require page dirtying.</strong> When a row is first read after being written, Postgres needs to check <code>pg_xact</code> to confirm the writing transaction committed. Once confirmed, it sets a hint bit in <code>t_infomask</code> to cache that result. Setting the hint bit dirties the page, which means writing it back to disk. On an append-only table with high read rates, hint bit setting is continuous background I/O on pages that will never change in any meaningful way. Welcome to your new normal.</p><p><strong>Since PostgreSQL 13, insert volume alone triggers autovacuum.</strong> Not just dead tuples. Postgres needs to periodically freeze old transaction IDs to prevent XID wraparound, which is a hard limit built into the 32-bit transaction counter. At high insert rates, autovacuum fires continuously just to freeze tuples on tables with zero updates.</p><p>Go check <code>autovacuum_count</code> and <code>vacuum_count</code> on your busiest append-only partition. They're climbing whether or not n_dead_tup is.</p><p>The result: autovacuum workers show up in <code>pg_stat_activity</code> at all hours on tables that never see a single <code>UPDATE</code>. You tune <code>autovacuum_vacuum_scale_factor</code> and <code>autovacuum_max_workers</code>, and it helps at the margin. But what you're tuning is how the cleanup process competes with writes. Not why it needs to run at all.</p><h2 id="the-write-amplification-chain">The write amplification chain</h2><p>Now let's connect all of this into the full cost picture.</p><p>A single 1 KB sensor reading doesn't write 1 KB. Here's what actually hits disk:</p><ul><li>23-byte heap tuple header plus padding</li><li>1,024 bytes of your actual row data</li><li>One entry per index, roughly 40 to 80 bytes each in B-tree leaf pages (five indexes = 200 to 400 bytes)</li><li>One WAL record per heap insert, one per index insertion: approximately 1.2 KB total</li><li>Periodically: an 8 KB full-page write after checkpoint for any newly dirtied page</li></ul><p>Total actual I/O: 2.5 to 3.5 KB for 1 KB of logical data.</p><p>The MVCC header is the entry point for this entire chain. It's what requires the visibility tracking, the hint bit mechanism, the autovacuum sweep, and the WAL record structure that Postgres uses.</p><p>At 100K inserts per second, you're writing 250 to 350 MB/sec of actual I/O for 100 MB/sec of application data. The 3 to 5x write amplification ratio isn't configuration. It's the cost of MVCC applied to data that will never be updated.</p><h2 id="why-you-cant-opt-out">Why you can't opt out</h2><p>There's no per-table setting to disable MVCC. No <code>append_only = true</code> flag that strips the header and skips the visibility machinery. MVCC is not a feature you can turn off for specific tables. It's the storage model. Every heap tuple gets the header. Every insert goes through the same write path.</p><p>This isn't an oversight. It's an architectural decision with a clear rationale: the storage engine doesn't know at write time what future transactions will need to see. The consistency guarantee requires the mechanism to be universal.</p><p>For most workloads, this is the right tradeoff. The overhead is small relative to the value of the concurrency guarantee, and mixed read/write workloads on shared rows are exactly what Postgres is built for.</p><p>The overhead only becomes the dominant cost when the workload is append-only at high sustained rates. That's when you're paying the full price for a guarantee you never exercise.</p><h2 id="what-changes-when-the-storage-model-changes">What changes when the storage model changes</h2><p>TimescaleDB's columnar storage (the Columnstore layer) addresses this at the architecture level, not the configuration level. Rather than writing one heap tuple per row, it batches up to 1,000 row versions per column into compressed arrays before writing to disk. The MVCC header overhead gets amortized across the batch. One write operation covers what would have been 1,000 individual heap tuple insertions.</p><p>The practical results: write amplification drops from 3 to 5x to near 1:1 for sustained append workloads. Autovacuum pressure drops proportionally because there's far less row-level churn to clean. WAL volume at 100K inserts/sec falls from 50 to 100 MB/sec to roughly 5 to 15 MB/sec. Replicas that previously fell behind during write peaks can keep up.</p><p>Everything else stays the same. Same SQL. Same wire protocol. Same extensions. Same tooling. The change is underneath, at the layer where MVCC overhead was accumulating.</p><h2 id="the-bottom-line">The bottom line</h2><p>MVCC is not a bug in Postgres. It's one of the reasons Postgres is the right choice for the majority of production workloads.</p><p>But if most of your rows are immutable after the insert commits, if your tables never see concurrent updates to the same rows, if autovacuum is running constantly on data you've never touched, you're running an append-only workload inside a concurrency model built for something else.</p><p>That's not misconfiguration. It's an architectural mismatch. The distinction matters because misconfiguration has a config fix. Architectural mismatch doesn't.</p><p>If high-frequency append-only ingestion describes what you're running, <a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>the full essay on the Optimization Treadmill</u></a> covers what this costs across your entire stack, and what the path forward looks like.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[When Continuous Ingestion Breaks Traditional Postgres]]></title>
            <description><![CDATA[Postgres maintenance depends on quiet periods your continuous workload eliminated. Here's what happens inside the database when the gaps disappear.]]></description>
            <link>https://www.tigerdata.com/blog/when-continuous-ingestion-breaks-traditional-postgres</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/when-continuous-ingestion-breaks-traditional-postgres</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Fri, 13 Mar 2026 19:33:45 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/03/When-Continuous-Ingestion-Breaks-Traditional-Postgres-1280x720.png">
            </media:content>
            <content:encoded><![CDATA[<p>Your system writes data constantly. Not in jobs. Not in batches. A stream that runs at 3am the same as it runs at 3pm. IoT sensors. Trade feeds. Metrics collectors. The data never stops.</p><p>For a while, Postgres handles it fine. Then you start noticing things. Autovacuum is always running. Write latency has a pattern you can't explain by traffic alone. Maintenance tasks that used to take minutes now take hours. And the really annoying part: nothing is misconfigured.</p><p>You check the usual suspects. Indexes are correct. Query plans look reasonable. Configs follow best practices. A colleague confirms the same.</p><p>The problem isn't a missing index or a bad query plan. The problem is that Postgres was designed with a quiet period baked into its assumptions. Your system eliminated that quiet period. Now you're paying for it.</p><h2 id="what-breathing-room-actually-means-in-postgres">What "breathing room" actually means in Postgres</h2><p>Most database systems are designed around a workload shape that includes peaks and valleys. Peaks are when users are active. Valleys are when the database catches up.</p><p>Postgres maintenance is built around the valley.</p><p>Autovacuum runs more aggressively when the database is quiet. <code>ANALYZE</code> refreshes statistics without competing for I/O. Checkpoint cycles complete cleanly. WAL accumulation clears out. The buffer cache warms up on predictable patterns.</p><p>Batch ETL fits this model perfectly. A nightly job writes data for two hours. The database writes, then rests, then writes again. Maintenance runs in the gaps. Everything resets before the next cycle starts.</p><p>Continuous ingestion has no gaps. The window that used to be quiet at 2am is now the same as the window at 2pm. Every maintenance process that depends on quiet time now runs in direct competition with writes. All day. All night.</p><h2 id="the-maintenance-competition-problem">The maintenance competition problem</h2><p>Three maintenance processes need quiet time and don't get it under continuous ingestion.</p><p><strong>Autovacuum.</strong> Even on append-only tables, autovacuum fires continuously at high insert rates. Since PostgreSQL 13, inserts themselves trigger autovacuum to freeze tuples and update the visibility map. This isn't about dead tuples from updates or deletes. It's insert-driven vacuum, running because the data is arriving too fast for the system to catch up.</p><p>At 50K inserts/second, autovacuum never finishes a cycle before the next one starts. It competes for I/O with your writes. When it loses, bloat accumulates. When it wins, write latency spikes.</p><p>There's no configuration fix for this. You can tune <code>autovacuum_vacuum_cost_delay</code> and <code>autovacuum_max_workers</code> all day. What you're tuning is how autovacuum loses gracefully. Not how it stops competing.</p><p><strong>Checkpoints.</strong> Postgres writes dirty pages to disk at checkpoint intervals. After a checkpoint completes, the first write to any previously-clean page triggers a full-page write to WAL (that's the <code>full_page_writes mechanism</code>, and it's on by default for good reason). At high insert rates, checkpoint cycles are constant. The full-page write burst that follows each one adds significant WAL volume on top of your baseline write load.</p><p>Batch systems checkpoint, rest, then return to normal. Continuous systems checkpoint and immediately start generating the next burst. There's no recovery window.</p><p><strong>ANALYZE and statistics.</strong> Query planning accuracy depends on fresh statistics. On a billion-row table, <code>ANALYZE</code> is expensive. On a batch system, you schedule it after the load completes. On a continuous system, there is no "after." You run it during writes or you let statistics go stale. Stale statistics mean bad query plans. Bad query plans mean unexpected sequential scans at the worst possible time.</p><h2 id="wal-as-the-throughput-ceiling-you-cant-tune-past">WAL as the throughput ceiling you can't tune past</h2><p>This is the mechanical core of the problem.</p><p>Every insert generates WAL. Heap insert record, index insertion records for every index on the table, plus full-page writes after checkpoints. A single 1KB sensor reading with five indexes generates roughly 2.5-3.5KB of actual I/O once you account for the heap tuple, B-tree leaf page insertions, and WAL records. At 100K inserts/second, that puts sustained WAL throughput at 50-100MB/sec under normal conditions. After a checkpoint, it spikes higher because of full-page writes.</p><p>That's 3-6GB per minute. 180-360GB per hour. Just WAL.</p><p>WAL writes are sequential and synchronous by default. That's a hard ceiling on write throughput for a given storage configuration. You can raise the ceiling by buying faster storage. You can't eliminate it, because WAL is how Postgres guarantees durability. And you shouldn't want to eliminate it. Durability matters. But you should understand that your write throughput has a physical upper bound set by how fast your storage can absorb WAL, and continuous ingestion pushes against that bound constantly.</p><p>Here's where continuous ingestion and batch ETL diverge completely.</p><p>Batch ETL generates bursts of WAL followed by silence. The silence lets replicas catch up. A streaming replica can fall behind during a batch load and recover in the gap. Nobody notices because the gap is long enough.</p><p>Continuous ingestion generates WAL constantly. Replicas that fall slightly behind have no gap to recover in. They fall further behind. The primary retains unprocessed WAL in <code>pg_wal</code>, consuming disk. The further behind the replica gets, the more WAL it needs to process, and the more disk the primary holds. It's a feedback loop. The thing that causes the problem (WAL volume) is the same thing that prevents recovery (WAL volume).</p><p>Adding replicas makes it worse, not better. Each replica is another consumer that needs to keep up with the same WAL stream, and the primary holds WAL until the slowest one catches up.</p><p>The standard fix is more provisioned IOPS. It works for a while. Then data volume grows and you're having the same conversation again, just with bigger numbers on the invoice.</p><h2 id="why-the-standard-toolkit-doesnt-solve-this">Why the standard toolkit doesn't solve this</h2><p>Walk through each common response and you'll see exactly where it runs out.</p><p><strong>More autovacuum workers.</strong> More workers means more I/O competition with writes, not less. You're distributing the problem across more processes. The aggregate I/O pressure is unchanged.</p><p><strong>Aggressive autovacuum cost limits.</strong> You can configure vacuum to run faster and harder. It cleans up faster but hits writes harder. There's no setting that makes the competition disappear. You're choosing which process suffers.</p><p><strong>More RAM.</strong> Bigger <code>shared_buffers</code> and page cache reduce physical reads. Write amplification is unchanged. WAL volume is unchanged. Autovacuum competition is unchanged. You bought better read performance for a write-bound problem.</p><p><strong>Faster storage.</strong> Raises the WAL ceiling. Doesn't change the ratio of actual I/O to logical data. At 3-5x write amplification, faster storage lets you sustain a higher write rate before hitting the ceiling. But data volume grows, and the ceiling moves up proportionally.</p><p><strong>Vertical scaling.</strong> Same as faster storage with more CPU. You've bought headroom measured in months. At the current data growth trajectory, that math doesn't improve over time.</p><p>Each of these is the right response to the symptom. None of them changes the underlying dynamic: continuous ingestion is in constant competition with the maintenance processes Postgres needs to stay healthy.</p><h2 id="the-workloads-where-this-actually-matters">The workloads where this actually matters</h2><p>Not every write-heavy system has this problem. Let's be precise.</p><p>The pattern shows up when three things are true at once: writes are continuous rather than bursty, data volume is growing on a sustained curve, and the database needs to stay queryable under latency requirements while ingestion is running.</p><p><strong>Industrial IoT</strong> is the clearest example. A wind farm with 10,000 sensors reporting every five seconds generates roughly 2,000 inserts/second. That's modest by financial or observability standards, but it never pauses. The turbines don't stop overnight. Maintenance windows don't exist because the data source doesn't know what a maintenance window is.</p><p><strong>Financial market data</strong> is the high-frequency version. Trade feeds run at hundreds of thousands of events per second during market hours. Pre-market and after-market data keeps coming. Systems that aggregate this data for risk and compliance queries need it available immediately, not at end of day.</p><p><strong>Observability platforms</strong> are the distributed version. Metrics, traces, and logs from thousands of hosts. Each host generates data independently. The aggregate rate is enormous and constant.</p><p>What these have in common: the data source runs on its own schedule, completely independent of what the database needs. The wind turbine doesn't care that autovacuum is behind. The trading engine doesn't wait for a checkpoint to finish.</p><p>If your write pattern is bursty (user-driven traffic, nightly batch jobs, periodic syncs), you probably don't have this problem. The database gets its breathing room, maintenance catches up, and standard Postgres optimization works the way it's supposed to. The pattern described in this post shows up specifically when the gap disappears.</p><h2 id="recognizing-the-pattern-early">Recognizing the pattern early</h2><p>The instinct when Postgres starts struggling under continuous ingestion is to tune harder. Add workers. Raise limits. Upgrade storage.</p><p>Those are correct responses for a database that has misconfiguration or a bad schema. Postgres is doing exactly what it was designed to do. The MVCC model, the WAL architecture, the maintenance scheduler: these are good design decisions for the workloads Postgres was built to handle. The system changed underneath it. That's not a criticism of the tool.</p><p>But continuous ingestion isn't a heavier version of batch ETL. It's a different workload class. The architectural assumptions underneath Postgres were built around a workload that breathes. Continuous ingestion doesn't breathe. And that distinction matters because it determines whether optimization will change your trajectory or just delay the same outcome.</p><p>Recognizing that early is worth a lot. At 50M rows, switching to a purpose-built architecture takes days. At 1B rows, it takes months. Every quarter you spend optimizing within the wrong architecture is a quarter where migration gets harder and the engineering team spends more time managing the database than building product.</p><p>If this sounds familiar, the full analysis covers the scoring framework and the mechanics behind why each optimization phase hits a ceiling. It's the same trajectory described here, zoomed out to show the complete path and where it leads.</p><p><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><strong><u>Read the full analysis: Understanding Postgres Performance Limits for Analytics on Live Data →</u></strong></a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Why Adding More Indexes Eventually Makes Things Worse]]></title>
            <description><![CDATA[Every Postgres index is a flat tax on every insert. At high ingestion rates, that tax is the whole problem.]]></description>
            <link>https://www.tigerdata.com/blog/why-adding-more-indexes-eventually-makes-things-worse</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/why-adding-more-indexes-eventually-makes-things-worse</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 11 Mar 2026 16:36:20 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/03/Option-1-1200X627.png">
            </media:content>
            <content:encoded><![CDATA[<p>The pattern is familiar. A query is slow. You run <code>EXPLAIN</code> and see a sequential scan. You add an index. The query drops from seconds to milliseconds.</p><p>You do this a dozen times over two years and it works every time.</p><p>Then write latency starts climbing and you can't figure out why. The queries are fast. The schema looks clean. Nothing is obviously wrong.</p><p>Pull up <code>pg_stat_user_indexes</code>. Count your indexes. Now think about what happens at the storage layer every time a row lands in that table.</p><p>The indexes didn't stop helping reads. They started hurting writes. Every index is a flat tax on every insert: one extra write operation per row, every time, no exceptions. At low ingestion rates, the tax is invisible. At high ingestion rates, it's the whole problem.</p><h2 id="what-actually-happens-when-you-insert-a-row">What actually happens when you insert a row</h2><p>No handwaving here. Let's walk through the mechanics.</p><p>A single <code>INSERT</code> into a table with five indexes doesn't write once. It writes six times: one heap tuple to the table's data pages, and one B-tree leaf page insertion per index. Each index insertion traverses the B-tree from root to leaf, finds the correct position, and writes the new entry. If the target leaf page is full, it splits. A split can cascade up the tree.</p><p>Then there's WAL. One heap insert record. Five index insertion records. If it's the first modification to a page since the last checkpoint, Postgres writes a full 8 KB page image on top of all that.</p><p>At one insert per second, this is completely invisible. At 50,000 inserts per second with five indexes, you're looking at 300,000 write operations per second. Not 50,000. Six times the logical write rate, minimum.</p><p>That's your write amplification number. For this table configuration: 6x. More indexes, higher multiplier.</p><h2 id="the-math-that-makes-this-concrete">The math that makes this concrete</h2><p>Take a table with five indexes and a 1 KB row. The heap tuple costs 23 bytes of header plus your 1,024 bytes of row data plus a 4-byte <code>ItemIdData</code> pointer. Each of the five B-tree index entries adds roughly 40 to 80 bytes. Then WAL: approximately 1.2 KB covering the heap insert plus all five index insertions. Add it up and you're writing roughly 2.5 to 3.5 KB for every 1 KB of logical data.</p><p>At 50K inserts/sec, that's 125 to 175 MB/sec of actual I/O for 50 MB/sec of application data. The index tax at work.</p><p>Now add two more indexes because a couple of new dashboard queries need covering indexes. You're at seven. The multiplier goes up. The WAL volume goes up. Write latency goes up. Autovacuum has more index pages to scan and maintain.</p><p>The relationship is linear per index, but the effect compounds with ingestion rate. At 1K inserts/sec, two extra indexes barely register. At 100K inserts/sec, they're a real cost.</p><p>Here's what the math looks like across different configurations:</p>
<!--kg-card-begin: html-->
<table style="border:none;border-collapse:collapse;"><colgroup><col width="64"><col width="184"><col width="184"><col width="192"></colgroup><tbody><tr style="height:39.25pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;text-align: center;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Indexes</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;text-align: center;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Write ops/sec @ 10K inserts</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;text-align: center;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Write ops/sec @ 50K inserts</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;text-align: center;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Write ops/sec @ 100K inserts</span></p></td></tr><tr style="height:25.75pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">1</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">20,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">100,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">200,000</span></p></td></tr><tr style="height:25.75pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">3</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">40,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">200,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">400,000</span></p></td></tr><tr style="height:25.75pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">5</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">60,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">300,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">600,000</span></p></td></tr><tr style="height:25.75pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">7</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">80,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">400,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">800,000</span></p></td></tr><tr style="height:25.75pt"><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">10</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">110,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">550,000</span></p></td><td style="vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;"><p dir="ltr" style="line-height:1.38;margin-top:12pt;margin-bottom:12pt;"><span style="font-size:11pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">1,100,000</span></p></td></tr></tbody></table>
<!--kg-card-end: html-->
<p>The numbers are approximate (real-world I/O depends on page splits, full-page writes, and your specific index types), but the pattern is clear. Each additional index is a flat tax on every insert. The tax rate doesn't change. The bill does.</p><h2 id="why-timestamp-indexes-have-a-specific-problem">Why timestamp indexes have a specific problem</h2><p>B-tree behavior for monotonically increasing keys is worse than for random keys. And most time-series tables insert in timestamp order.</p><p>With a random key distribution, new inserts scatter across the B-tree's leaf layer. Any given leaf page gets a roughly even share of new entries. Splits happen, but they're spread out.</p><p>With a timestamp key, every insert goes to the rightmost leaf page. The same page, over and over. That page fills up and splits. The new rightmost page fills up and splits. This is called a "hot right edge," and it means B-tree index maintenance for timestamp columns involves constant page splits concentrated in one area of the tree.</p><p>The old leaf pages that were once the rightmost page sit mostly empty but remain allocated. Index size grows faster than data size. The index bloat you see in pg_stat_user_indexes is a direct result of this pattern, not random fragmentation.</p><p>For non-timestamp indexes on the same table (device ID, metric name, sensor type), inserts scatter across the tree instead, which means random I/O rather than sequential. So you get two different flavors of write overhead hitting the same table simultaneously: constant splits on the timestamp index, random I/O on everything else.</p><h2 id="the-feedback-loop">The feedback loop</h2><p>All of that overhead is manageable if it stays constant. The problem is that it doesn't. It self-reinforces.</p><p>You add indexes to fix slow queries. Write amplification increases. Write latency creeps up. Bloat accumulates faster. Autovacuum fires more frequently and has more index pages to clean. Autovacuum competes with your writes for I/O bandwidth. Write latency climbs higher.</p><p>Slower writes mean rows sit in the buffer longer. Buffer pressure increases. The query performance you were trying to protect starts degrading anyway, now from I/O contention rather than missing indexes.</p><p>The response is usually to check query plans again. Some queries have gone back to sequential scans because statistics are stale or the planner is making different cost estimates under load. So you add another index. The cycle repeats.</p><p>This loop runs slowly enough that the connection between each index addition and the eventual write degradation is hard to see. Six months can pass between the two events. By that point, you've forgotten which indexes were added and why, and the symptom looks like a completely different problem.</p><h2 id="the-diagnostic-questions">The diagnostic questions</h2><p>Before adding the next index, ask these:</p><p><strong>How many indexes does this table already have?</strong> Pull <code>pg_stat_user_indexes</code> and look at <code>idx_scan</code>. Indexes with low scan counts are paying full write overhead for queries that run rarely or never.</p><p><strong>What's the actual write rate on this table?</strong> Low ingestion rate tables can carry many indexes without much penalty. The math only gets ugly at high sustained rates. If you're inserting 100 rows/sec, ten indexes are probably fine. If you're inserting 50K rows/sec, every index counts.</p><p><strong>Is the slow query a read problem or a write problem?</strong> Adding an index to fix a slow query while write amplification is already the bottleneck treats the symptom and makes the underlying condition worse.</p><p><strong>What's the index bloat trend?</strong> Growing index size relative to table size, especially on timestamp columns, is the fingerprint of the hot right edge problem. You can measure it directly with <code>pgstattuple</code> or by comparing <code>pg_relation_size</code> for the index against the table over time.</p><p><strong>Could a different query shape eliminate the need for this index?</strong> Sometimes the answer is restructuring the query or adjusting the access pattern, not adding another index to support the query as written.</p><h2 id="when-youre-past-the-point-where-index-pruning-helps">When you're past the point where index pruning helps</h2><p>You can drop indexes with low idx_scan counts. You can consolidate partial indexes. You can audit and remove redundant coverage. All of that is correct and worth doing.</p><p>But for a table with continuous high-frequency ingestion, even a minimal index set still generates substantial write amplification. Three carefully chosen indexes on a 50K inserts/sec table is still 200K write operations per second. WAL volume is still 3–5x logical data volume. Autovacuum is still competing for I/O.</p><p>Index pruning buys back headroom. It doesn't change the architecture.</p><p>The write amplification problem for this class of workload is in the storage model itself. Row-based heap storage with B-tree indexes is how Postgres handles every table. It's the right design for most workloads. For sustained high-frequency, append-heavy ingestion, the overhead is intrinsic. It's not a configuration problem you can tune your way out of.</p><p>This is what changes when the storage model changes. The reason the index tax is so expensive in row-based storage is that every row is an independent write event. One heap insert, one WAL record, one B-tree traversal per index. The cost is per-row because the storage is per-row.</p><p>Columnar storage changes the unit of work. Instead of writing one row at a time, it batches thousands of row versions into a single segment before writing. One WAL record covers the whole batch. Index maintenance happens at the segment level, not the row level. The per-row tax that makes five indexes expensive at 50K inserts/sec gets amortized across thousands of rows per write. Write amplification drops from the 3 to 5x range to near 1:1.</p><p>That's not a tuning improvement. It's a different cost structure for the same logical operation. We covered the full architecture in <a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill"><u>The Postgres Optimization Treadmill</u></a>, which walks through why these constraints exist in row-based Postgres and what it looks like when the storage layer is built for this workload pattern from the start.</p><h2 id="the-bottom-line">The bottom line</h2><p>Every index you've ever added was the right call at the time. That's not the argument here.</p><p>The point is that the index tax is a <em>real cost</em> with a specific multiplier, and that multiplier matters a lot more at 50K inserts/sec than it does at 500. If write latency is climbing on a table that looks well-indexed, pull the insert rate and count the indexes. Do the multiplication. The answer is usually sitting right there in the numbers.</p><p>And if those numbers show you're paying five or more index taxes on every row, with no signs of the data slowing down, the question isn't which indexes to drop. It's whether the per-row cost structure is the right one for the workload.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Vertical Scaling: Buying Time You Can't Afford]]></title>
            <description><![CDATA[Postgres vertical scaling works, until it doesn't. Learn why high-frequency ingestion workloads hit an architectural wall and what to do about it.]]></description>
            <link>https://www.tigerdata.com/blog/vertical-scaling-buying-time-you-cant-afford</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/vertical-scaling-buying-time-you-cant-afford</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Thu, 26 Feb 2026 14:48:27 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/02/Blog-Thumbnail-1280x720.png">
            </media:content>
            <content:encoded><![CDATA[<p>Your Postgres database is struggling. Write latency is climbing, autovacuum is fighting for I/O, and the indexes you added three months ago aren't cutting it anymore. So you do the obvious thing.</p><p>You upgrade the instance. Metrics drop. Everyone exhales.</p><p>Six months later, you do it again.</p><p>Nobody puts this in a postmortem, because vertical scaling works. That's why teams keep reaching for it. But if you're running continuous high-frequency ingestion on Postgres, it's not a fix. It's a payment plan on a debt that keeps growing.</p><h2 id="the-cost-curve-doesnt-lie">The Cost Curve Doesn't Lie</h2><p>You've probably already run the numbers. At 50K inserts per second, you're adding roughly 1.5 billion rows per year. Your data volume curve is exponential. Your infrastructure cost moves in steps, doubling each time you provision the next tier up.</p><p>Plot both lines on the same chart. Watch them diverge.</p><p>You upgrade from 16 vCPU/64GB to 32 vCPU/128GB with provisioned IOPS (io2 at 10,000+ on AWS, say). Cost roughly doubles. You get six months of breathing room. Then the data keeps growing, and the metrics start climbing again.</p><p>So you upgrade again. The cost doubles again. Twelve months out, you're projecting another upgrade. The database line item is growing faster than the product revenue it supports.</p><p>Oof.</p><h2 id="what-youre-actually-buying">What You're Actually Buying</h2><p>More CPU gives autovacuum room to run without starving query execution. More RAM improves <code>shared_buffers</code> and OS page cache hit rates. Faster storage reduces I/O wait across the board.</p><p>All real wins. None of them touch the per-row overhead.</p><p>Here's what's actually happening underneath. At 100K inserts per second, you're writing 250-350MB of actual I/O for 100MB of application data. Every row carries MVCC headers, index entries, and WAL records whether you asked for them or not. A 1KB sensor reading becomes roughly 2.5 to 3.5KB of actual I/O: 23-byte heap tuple header, five index entries at ~60 bytes each, plus a ~1.2KB WAL record stacking on top.</p><p>At 100K inserts/sec, that's 250-350MB/sec of real I/O to move 100MB/sec of data. A bigger instance tolerates that overhead more gracefully. It does not reduce it.</p><p>So the trajectory holds. Six months of headroom, metrics creep back, another upgrade, another budget conversation. Each step costs more than the last one and buys roughly the same amount of time.</p><h2 id="the-invisible-cost-nobody-tracks">The Invisible Cost Nobody Tracks</h2><p>Here's where it gets uncomfortable. The latency graphs are one thing. Engineers watch latency graphs. Finance watches the <em>invoice</em>.</p><p>At some point the database line item becomes visible enough that someone schedules a meeting. Now you're explaining autovacuum to a person who manages a spreadsheet for a living. (That meeting is not fun. The prep work for that meeting costs engineering time you don't have.)</p><p>But that's the visible cost. The invisible one is worse.</p><p>When teams hit this pattern, senior engineers typically spend 20-30% of their time on database operations. Not firefighting. Weekly. Monitoring autovacuum lag. Tuning per-partition settings. Watching replication delay. Reviewing runbooks before anyone touches the schema. Making sure the pg_partman automation didn't silently fail again.</p><p>None of that shows up in the cloud bill. It doesn't trigger a finance meeting – it just quietly drains your best people every single week. New engineers need weeks of onboarding before they can safely operate the partitioning scheme. What should be a one-person schema change becomes a team event with a rollback plan.</p><p>You've built a database operations practice inside your product engineering team. That wasn't the plan.</p><h2 id="why-vertical-scaling-feels-like-its-working">Why Vertical Scaling Feels Like It's Working</h2><p>The thing that makes this pattern so persistent is that each optimization phase genuinely does help. Vertical scaling is no exception.</p><p>You add the bigger instance, and autovacuum workers stop competing with queries for CPU. Shared buffers expand, and buffer cache hit rates climb. Those io2 IOPS stop being the bottleneck. For a while, the system breathes.</p><p>But here's the thing: Postgres wasn't designed for continuous, high-frequency, append-only ingestion at scale. The design choices that make it excellent for general-purpose workloads, MVCC for concurrency, row-based heap storage, B-tree indexes, the WAL architecture – all generate overhead that multiplies when you're hammering it with hundreds of thousands of inserts per second that never pause.</p><p>Vertical scaling gives the existing architecture more room to operate. It doesn't change the architecture.</p><p>MVCC creates per-tuple overhead on data you'll never update. Row storage forces you to read all 30 columns when your query needs two. B-tree indexes mean every insert has to traverse and update every index, and at 50K inserts/sec with five indexes, that's 250K index insertions per second. WAL records every single one of those operations before touching a data page, so at 100K inserts/sec you're generating 50-100MB/sec of WAL just to do normal work.</p><p>None of those problems shrink when you add more vCPUs.</p><h2 id="how-it-shows-up-before-its-a-crisis">How It Shows Up Before It's a Crisis</h2><p>The real tell isn't in a p95 latency chart. It's the <em>pattern</em>.</p><p>You optimize. You get relief. The metrics climb back. You optimize again. The relief lasts a little less time than before.</p><p>Before it becomes a full crisis, it shows up in how the team is spending its time.</p><p>Optimization is on every quarterly roadmap, not as a one-time project, but as a line item, every quarter, competing with features for engineering time.</p><p>The database bill goes up 40% while user growth was 15%. Finance notices. Those numbers don't get ignored.</p><p>You ship a 2x performance improvement and data growth erases it within two quarters. The treadmill doesn't slow down – it <strong>speeds up</strong>.</p><p>And autovacuum just keeps coming up! It's in the top five processes by CPU and I/O at all hours and tuning it is somehow <em>always</em> on someone's plate.</p><p>Two or three of these? Pay attention. Four? You're <em>already</em> in the pattern.</p><h2 id="optimization-problem-vs-architecture-problem">Optimization Problem vs. Architecture Problem</h2><p>There are two different problems that both show up as "database performance is degrading."</p><p>The first is an optimization problem. The workload fits the database design. Better indexes, query rewrites, config tuning, vertical scaling. These directly improve the trajectory, and Postgres expertise solves it. For most workloads, vanilla Postgres is the right tool and this is the right path.</p><p>The second is an <strong>architectural mismatch</strong>. The workload is hitting design tradeoffs baked into the storage engine and the write path. Optimization helps short-term, but it doesn't change the trajectory. You're working <em>around</em> the architecture instead of <em>with</em> it.</p><p>Both of these look identical from the outside: degrading query latency, climbing infrastructure costs, teams spending more time on database operations than product work. The difference only becomes obvious when you notice each fix is lasting a little less time than the last one.</p><p>Vertical scaling is the right move for the first problem. For the second, it's just the most expensive item on the treadmill.</p><h2 id="when-to-think-about-architecture-instead">When to Think About Architecture Instead</h2><p>If your workload is continuous high-frequency ingestion, your data is append-only, queries predominantly filter on time ranges, and you're measuring retention in months or years, you're probably dealing with an architectural mismatch, not an optimization problem.</p><p>You also don't need to replace Postgres. TimescaleDB extends vanilla Postgres with columnar compression, hypertables with automatic chunking, and a query planner that understands <a href="https://www.tigerdata.com/learn/the-best-time-series-databases-compared" rel="noreferrer">time-based access patterns</a>. You keep SQL, your extensions, your team's knowledge, and the entire Postgres ecosystem. What changes is the storage engine and write path underneath (the parts <em>actually</em> generating the overhead).</p><p>Migration complexity scales with data volume. At 10M-50M rows, it's days to two weeks. At 100M-500M rows, two to six weeks. At 1B+, you're looking at months. Those hours don't go toward product features. And there's no point on that curve where waiting makes it cheaper.</p><p>If your team is spending 20%+ of engineering time on database operations and scalability is on every quarterly roadmap, you already know something is off. The upgrade cycles don't get cheaper. They just get further apart until they don't.</p><p><em>This post is part of a series on Postgres performance limits for high-frequency data workloads. The full analysis, including a workload scoring framework and migration complexity breakdown at different scales, is in the anchor essay:</em><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill" rel="noreferrer"><em> <u>Understanding Postgres Performance Limits for Analytics on Live Data</u></em></a><em>. Ready to test it on your own data?</em><a href="https://console.cloud.timescale.com/signup"><em> <u>Start a free Tiger Data trial.</u></em></a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Understanding Postgres Performance Limits for Analytics on Live Data]]></title>
            <description><![CDATA[PostgreSQL hits hard limits under analytics workloads. Here's why MVCC, WAL, and row storage compound — and what to do instead.]]></description>
            <link>https://www.tigerdata.com/blog/postgres-optimization-treadmill</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/postgres-optimization-treadmill</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[Analytics]]></category>
            <category><![CDATA[PostgreSQL Performance]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 25 Feb 2026 19:18:16 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/02/advocacy-essay-thumbnail-with-elephant.png">
            </media:content>
            <content:encoded><![CDATA[<h2 id="the-pattern-recognition-moment">The Pattern Recognition Moment</h2>
<p>You're reviewing monitoring on a normal workday. There hasn't been a new deployment, no weird traffic spike, and no schema changes. But p95 write latency has crept from 8ms to 25ms over the past month, and last week it touched 45ms. Your largest tables crossed 500M rows sometime in March and they're still climbing.</p>
<p>Six weeks of data points, all trending the same direction.</p>
<p><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/02/diagram-2-p95-write-latency.png" alt="" loading="lazy"></p>
<p>You've run Postgres in production for years. You've tuned queries, rebuilt indexes, and right-sized instances. But this time the fixes don't stick; every new index or config tweak brings the metrics back down for a few weeks, then they climb again. You can plot the trajectory out three months and know exactly where it lands.</p>
<p>So you do a proper audit: query plans, connection overhead, table stats, bloat. Everything checks out. Schema is sound, indexes cover the hot paths, and configs follow best practices. A consultant confirms the same: nothing misconfigured. But performance keeps degrading, and it correlates with data volume, not traffic.</p>
<p>You look closer at the workload. Most writes are inserts, not updates, and every row carries a timestamp. Queries almost always filter by time range. Data arrives continuously, not in batches or bursts, but as a steady stream that never pauses. You need months or years of retention, and you're not just storing this data. You're querying it under latency requirements.</p>
<p>This doesn't fit the profile of a transactional workload, and it doesn't fit a data warehouse either. It's continuous high-frequency ingestion that needs to stay operationally queryable.</p>
<p>Postgres is a brilliant general-purpose database. The same design choices that make it handle e-commerce, SaaS backends, and CMS workloads so well create compounding overhead for sustained high-frequency <a href="https://www.tigerdata.com/learn/time-series-database-what-it-is-how-it-works-and-when-you-need-one">time-series</a> ingestion with long retention. Design tradeoffs, not bugs. Baked into the architecture by intent.</p>
<p>You are not fighting misconfiguration. You are fighting architectural boundaries designed for a different workload class.</p>
<p>This piece walks through what we call the Optimization Treadmill: the sequence of phases most teams follow, each a correct response to observed symptoms, each providing temporary relief without changing the underlying trajectory. Understanding the mechanics of why the treadmill exists is what lets you recognize it early. If you recognize the scenario above, this is a common path. The question isn't whether you'll hit the ceiling. It's when, and how much runway you have left when you do.</p>
<h2 id="what-this-workload-looks-like">What This Workload Looks Like</h2>
<p>Not all high-write workloads will hit this wall. Postgres handles enormous write volumes for e-commerce, social networks, and SaaS backends without issue. The friction comes from a specific combination of six characteristics. If four or five describe your system, the optimization phases in the next section will be familiar.</p>
<p><strong>Continuous high-frequency ingestion.</strong> Thousands to hundreds of thousands of inserts per second, 24/7, with no pause: IoT sensors reporting every few seconds, financial systems processing trades in real time, or APM platforms collecting metrics from thousands of hosts. High-frequency data generation is independent of user count. Batch systems get quiet periods where the database can run maintenance, but continuous ingestion never stops. Maintenance competes directly with writes, and there is no scheduling window.</p>
<p><strong>Time-series access patterns.</strong> Nearly every row has a timestamp, and queries almost always include time range filters. "Last 30 minutes of CPU utilization," "this week compared to last week," "all transactions between two dates." This goes beyond a <code>created_at</code> column; the entire query pattern revolves around time. General-purpose indexes aren't built for this access pattern, which is why teams end up reimplementing time-based data organization through manual partitioning scripts and custom tooling.</p>
<p><strong>Append-only data.</strong> Once written, rows rarely change. Sensor readings don't get updated, financial transactions are immutable, log entries are permanent. Deletes happen in bulk (drop an entire month's partition), not row by row. MVCC exists to handle concurrent reads and writes on the same rows. Append-only workloads pay that overhead on data they never touch again. Autovacuum is running constantly just to clean up dead tuples that were never created through updates.</p>
<p><strong>Long retention.</strong> Months to years, not days or weeks. Compliance might require seven years of financial records, manufacturing teams need root cause analysis across quarters, and ML pipelines need two-plus years of training data. Shortening retention will just hide architectural problems because old data ages out, and long retention means unbounded table growth. At 50K inserts per second, that's roughly 1.5 billion rows per year. After three years? 4.5 billion rows.</p>
<p><strong>Operational query requirements.</strong> This isn't cold storage or an analytics warehouse you query once a day. You need millisecond responses on the last day's data, sub-second on the last week, and reasonable performance across the full retention window. Real-time dashboards, alert systems, user-facing analytics, ad-hoc investigation, all querying the same database. Data warehouse depth with operational latency requirements.</p>
<p><strong>Sustained growth.</strong> Data volume growing 50–100%+ year over year on a predictable curve. Static workloads can be over-provisioned once and left alone, but growing workloads demand constant re-optimization. You're not solving for current scale. You're chasing projected scale, and the gap keeps widening.</p>
<p>If four or five of these apply, the next section maps the optimization path most teams follow. If your workload is standard OLTP, batch warehouse, low-volume time-series, or short-retention, the underlying issues are likely different.</p>
<p>This combination of characteristics didn't exist at scale 15 years ago. It's a product of specific infrastructure shifts: billions of connected devices generating continuous telemetry, high-frequency trading systems that treat microseconds as a competitive moat, AI pipelines that require years of operational history as training data, and observability platforms collecting metrics from every process in a distributed system. The cloud didn't just scale these workloads up. It made them continuous. Machines that never go offline generate data that never stops. That changed what operational databases are asked to do, and general-purpose engines weren't redesigned to match.</p>
<h2 id="the-optimization-path">The Optimization Path</h2>
<p>Most teams working this pattern follow roughly the same sequence. Each phase is a reasonable response to observed symptoms, but each buys 3–6 months of relief at most, adds operational complexity, and has diminishing returns. The optimizations address symptoms without changing the underlying architecture. The ceiling doesn't move. You do, until you run out of room.</p>
<h3 id="phase-1-index-optimization">Phase 1: Index optimization</h3>
<p>The trigger is predictable: query performance degrades as tables grow past 50–100M rows, or sequential scans on a 100M-row table take minutes. The textbook answer is to add B-tree indexes on timestamp columns, build composite indexes for common filter combinations, create partial indexes on hot time ranges, and run ANALYZE to refresh <code>pg_statistic</code>.</p>
<pre><code class="language-sql">-- Composite index for the most common dashboard query pattern
CREATE INDEX idx_metrics_device_time
  ON device_metrics (device_id, ts DESC);

-- Partial index covering only the hot partition
CREATE INDEX idx_metrics_recent
  ON device_metrics (ts DESC)
  WHERE ts &gt; now() - interval '7 days';
</code></pre>
<p>A query that did a sequential scan across 100M rows now hits an index and returns in milliseconds. 10–100x improvement on read performance is typical. Problem solved, for now.</p>
<p>Issues start showing up as tables grow past 300M rows. Every INSERT must update every index on the table. With five indexes, each insert performs six write operations: one heap tuple write and five B-tree leaf page insertions. At 50K inserts/sec, that's 300K write operations per second. Each index insertion traverses the B-tree, potentially causing page splits that trigger additional I/O. <code>pg_stat_user_indexes</code> starts showing index bloat climbing:</p>
<pre><code class="language-sql">-- Monitoring index bloat
SELECT schemaname, tablename, indexname,
       pg_size_pretty(pg_relation_size(indexrelid)) as index_size,
       idx_scan as index_scans,
       idx_tup_read,
       idx_tup_fetch
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
</code></pre>
<p>Index sizes grow faster than table sizes because B-trees don't reclaim space efficiently for append-heavy, time-ordered data. For keys that increase monotonically like timestamps, inserts concentrate on the rightmost leaf pages, resulting in repeated splits. Old leaf pages become sparse but remain allocated. You've improved read latency at the cost of write throughput, and this workload needs both.</p>
<h3 id="phase-2-table-partitioning">Phase 2: Table partitioning</h3>
<p>Your largest table has crossed 800M to 1B rows, and dropping old data via DELETE causes table bloat and long-running transactions that block autovacuum. You implement time-based range partitioning (typically daily or weekly).</p>
<pre><code class="language-sql">-- Partitioned table setup
CREATE TABLE device_metrics (
    ts          timestamptz NOT NULL,
    device_id   bigint NOT NULL,
    metric      text NOT NULL,
    value       double precision
) PARTITION BY RANGE (ts);

-- Daily partitions created by cron or pg_partman
CREATE TABLE device_metrics_20250601
  PARTITION OF device_metrics
  FOR VALUES FROM ('2025-06-01') TO ('2025-06-02');
</code></pre>
<p>Implementation requires automation: cron jobs or pg_partman to create future partitions, monitoring to detect gaps where partition creation failed, and careful handling of queries that span partition boundaries. Backup and restore now operates on hundreds of individual tables, <code>pg_dump</code> time scales with partition count, and schema migrations touch every partition.</p>
<p>The wins are concrete. Queries with time-range filters trigger partition pruning, and EXPLAIN shows the planner excluding irrelevant partitions:</p>
<pre><code class="language-sql">EXPLAIN SELECT avg(value) FROM device_metrics
WHERE ts &gt; now() - interval '1 hour';

-- Scans 1-2 partitions instead of the entire table
-- "Partitions removed: 498 of 500"
</code></pre>
<p>Dropping old data becomes <code>DROP TABLE device_metrics_20240101</code> instead of a multi-hour DELETE that generates gigabytes of WAL and dead tuples.</p>
<p>What happens at 500+ partitions? The <a href="https://www.postgresql.org/docs/current/ddl-partitioning.html">PostgreSQL documentation on partitioning best practices</a> is direct about the cost: "Planning times become longer and memory consumption becomes higher when more partitions remain after the planner performs partition pruning." <code>pg_partman</code> maintenance jobs occasionally fail silently, leaving gaps. Queries spanning long ranges (quarterly reports, year-over-year comparisons) hit hundreds of partitions and regress in performance. Each active partition still has its own autovacuum overhead. The write path is faster per-partition but aggregate write load is unchanged. And the operational complexity is real. New engineers need to understand the partitioning scheme, the automation scripts, the monitoring for gaps, the procedures for backfills, and the implications for schema changes.</p>
<h3 id="phase-3-autovacuum-tuning">Phase 3: Autovacuum tuning</h3>
<p>This is where it starts to feel wrong. You're tuning a cleanup process for data you never modify. <code>n_dead_tup</code> counts are climbing on active partitions, <code>last_autovacuum</code> timestamps show vacuum running constantly but falling behind during write peaks, and <code>pg_stat_activity</code> regularly shows autovacuum workers competing for I/O.</p>
<p>Even append-only workloads generate work for autovacuum. Aborted transactions leave dead tuples. Hint-bit setting (marking tuples as known-committed or known-aborted to avoid future <code>pg_xact</code> lookups) requires dirtying pages. And since PostgreSQL 13, autovacuum triggers based on insert count (not just dead tuples) specifically to freeze tuples and update the visibility map. At high insert rates, this means autovacuum fires continuously on tables that never see a single UPDATE or DELETE.</p>
<pre><code class="language-sql">-- Per-table autovacuum settings on high-traffic partitions
ALTER TABLE device_metrics_20250601 SET (
    autovacuum_vacuum_scale_factor = 0.01,    -- default 0.2
    autovacuum_vacuum_cost_delay = 2,         -- default 2ms (20ms before PG 12)
    autovacuum_vacuum_cost_limit = 1000       -- default 200
);
</code></pre>
<pre><code># postgresql.conf adjustments
autovacuum_max_workers = 6            # default 3
autovacuum_naptime = 15s              # default 1min
maintenance_work_mem = 2GB            # default 64MB
autovacuum_vacuum_cost_delay = 2ms
autovacuum_vacuum_cost_limit = 800
</code></pre>
<p>This helps stabilize bloat, and <code>pg_stat_user_tables.n_dead_tup</code> stays under control. But autovacuum workers now consume measurable CPU and I/O continuously, and monitoring shows autovacuum in <code>pg_stat_activity</code> at all hours. During write peaks, vacuum falls behind, bloat creeps back, and query performance becomes variable. You're tuning a process that exists to clean up overhead your workload doesn't fundamentally produce, but that the storage engine creates anyway.</p>
<h3 id="phase-4-vertical-scaling">Phase 4: Vertical scaling</h3>
<p>All of your optimizations are showing diminishing returns. The next logical step is to add more resources: upgrade from 16 vCPU/64GB to 32 vCPU/128GB with provisioned IOPS storage (e.g., io2 at 10,000+ IOPS on AWS).</p>
<p>More CPU gives autovacuum workers room to operate without starving query execution. More RAM increases <code>shared_buffers</code> and OS page cache hit rates, reducing physical disk reads. Faster storage reduces I/O wait time across the board. This gives you roughly six months of headroom.</p>
<p>Math doesn't lie: the infrastructure cost doubled or tripled, but data growth is still exponential. At the current trajectory, you'll need another upgrade in 12 months. The database cost line item is growing faster than the product revenue it supports.</p>
<h3 id="phase-5-read-replicas">Phase 5: Read replicas</h3>
<p>Dashboards and analytics queries compete with ingestion for CPU and I/O on the primary. You add 1–3 streaming replicas, configure pgbouncer or pgpool to route read traffic, and separate the connection pools. Immediately, write performance on the primary improves. Expensive analytical queries run against replicas without blocking ingestion.</p>
<p>The primary still carries the full write load. At sustained high insert rates generating tens of megabytes per second of WAL, replicas that fall behind accumulate WAL on the primary, consuming disk. The further behind a replica gets, the more WAL the primary must retain, and high write volume is exactly what causes replicas to fall behind in the first place. Real-time dashboards pointing at lagging replicas show stale data. You're now managing multiple Postgres instances with their own monitoring, autovacuum tuning, and connection pooling. The write bottleneck is still untouched.</p>
<h3 id="taking-stock">Taking stock</h3>
<p>After all five phases, this is what the infrastructure looks like: partitioned tables across 500+ partitions with <code>pg_partman</code> automation and monitoring, aggressive per-table autovacuum settings under constant adjustment, instances upgraded 2–3x from original specs with provisioned IOPS, 2–3 streaming replicas with connection-level routing, detailed runbooks covering partition management, vacuum procedures, and failover scenarios.</p>
<p>Each optimization was the right response. Each bought time. Yet the trajectory is unchanged.</p>
<p><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/02/diagram-1-latency-across-optimization-phases.png" alt="" loading="lazy"></p>
<p>Senior engineers are now spending 20–30% of their time on database operations. Quarterly planning includes a database scalability line item. New hire onboarding takes weeks before someone can safely operate the partitioning scheme. The team has become part product engineering, part DBA.</p>
<p>Is this inherent to the scale, or is it inherent to the architecture?</p>
<p>The answer matters because the two problems have different solutions. Optimization within the right architecture has a ceiling you can raise. Optimization against an architectural mismatch has a ceiling that doesn't move. Only the timeline changes. For this workload pattern, the ceiling is structural. The question was never if you'd hit it. It was always when.</p>
<h2 id="why-these-optimizations-hit-a-ceiling">Why These Optimizations Hit a Ceiling</h2>
<p>The optimization phases above aren't ineffective. Each one operates within architectural boundaries that weren't designed for this workload pattern, and those boundaries constrain how much any optimization can actually move the needle. Understanding the mechanics explains why returns diminish.</p>
<p>Postgres is a brilliant general-purpose relational database. Its design handles an enormous range of workloads well: e-commerce, content management, authentication, SaaS backends. "General-purpose" means optimized for the average case. High-frequency time-series ingestion with long retention is not the average case. Four core design decisions create this compounding overhead.</p>
<h3 id="mvcc-multi-version-concurrency-control">MVCC (Multi-Version Concurrency Control)</h3>
<p>MVCC lets readers and writers operate concurrently without lock contention. The <a href="https://www.postgresql.org/docs/current/mvcc-intro.html">PostgreSQL documentation on concurrency control</a> describes the core guarantee: "reading never blocks writing and writing never blocks reading." When a row is updated, Postgres keeps the old tuple version visible to in-flight transactions, and autovacuum later marks dead tuples as reusable. For workloads with concurrent reads and updates on shared rows, this is an excellent tradeoff.</p>
<p>For append-only ingestion, every insert still pays the full MVCC cost. Each heap tuple carries a fixed-size header (23 bytes on most machines) containing <code>t_xmin</code>, <code>t_xmax</code>, <code>t_cid</code>, <code>t_ctid</code>, <code>t_infomask</code>, <code>t_infomask2</code>, and <code>t_hoff</code>. These fields track transaction visibility, even though the row will never be updated or deleted by a transaction. Extra cost with no extra value.</p>
<p>The write amplification is easily observable. A 1KB sensor reading becomes:</p>
<ul>
<li>23-byte heap tuple header (plus alignment padding and a 4-byte <code>ItemIdData</code> pointer)</li>
<li>1,024 bytes of row data</li>
<li>5 index entries (assuming 5 indexes, ~40–80 bytes each in B-tree leaf pages)</li>
<li>~1.2KB WAL record (heap insert + index insertions)</li>
</ul>
<p>Total actual I/O: roughly 2.5–3.5KB per 1KB of logical data. At 100K inserts/sec of 1KB rows, you're writing 250–350MB/sec of actual I/O for 100MB/sec of application data. The exact ratio varies with row width, index count, and whether <code>full_page_writes</code> triggers after a checkpoint.</p>
<p><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/02/diagram-3-logical-data-vs-IO-breakdown.png" alt="" loading="lazy"></p>
<p>Autovacuum still has work to do on append-only tables. Aborted transactions leave dead tuples, and hint-bit setting (marking tuples as known-committed or known-aborted to avoid future <code>pg_xact</code> lookups) requires dirtying pages. At high insert rates, even these minor sources of work keep autovacuum continuously active. <code>pg_stat_user_tables.n_dead_tup</code> may stay low, but <code>vacuum_count</code> and <code>autovacuum_count</code> keep climbing steadily.</p>
<h3 id="row-based-storage-with-b-tree-indexes">Row-based storage with B-tree indexes</h3>
<p>Postgres stores data as a heap of 8KB pages, each containing variable-length tuples laid out row by row. Every tuple contains all columns. B-tree indexes map key values to ctid (page number + offset) pointers into the heap.</p>
<p>For time-series analytics, this creates read amplification:</p>
<pre><code class="language-sql">SELECT avg(temperature)
FROM sensor_readings
WHERE ts &gt; now() - interval '1 hour'
  AND device_id = 42;
</code></pre>
<p>This query needs two columns: <code>ts</code> and <code>temperature</code>. If the table has 30 columns, Postgres reads all 30 columns for every matching row from the heap pages. The I/O is 15x what a columnar layout would require, where only the referenced columns are read from disk.</p>
<p>Time-series data also compresses extremely well in columnar formats. Sequential timestamps delta-encode to near-zero storage (a regular interval collapses from 8 bytes per timestamp down to a single bit via delta-of-delta encoding), and repeated device IDs run-length-encode. Floating-point sensor values compress with XOR-based compression derived from Facebook's Gorilla algorithm (<a href="http://www.vldb.org/pvldb/vol8/p1816-teller.pdf">Pelkonen et al., "Gorilla: A Fast, Scalable, In-Memory Time Series Database," VLDB, 2015</a>). Columnar storage routinely achieves 10–20x compression on time-series data. Row-based heap storage can't apply any of these techniques because values from different columns are interleaved on the same page.</p>
<p>On the write side, B-tree index maintenance creates significant overhead. Each insert traverses every index's B-tree from root to leaf, finds the correct leaf page, and inserts the new entry. If the leaf page is full, it splits, which can cascade up the tree. For time-ordered data, inserts concentrate on the right edge of timestamp indexes, creating contention on a small number of leaf pages. Non-timestamp indexes (device ID, metric type) scatter inserts across the tree, causing random I/O. With five indexes on a table, every row insert performs one heap page write, five B-tree traversals and leaf page insertions, plus WAL records for each. At 50K inserts/sec, that's 50K heap writes + 250K index insertions per second.</p>
<h3 id="query-planning-overhead">Query planning overhead</h3>
<p>The Postgres planner runs a full optimization pass on every query: it enumerates possible paths, estimates costs from <code>pg_statistic</code> entries, considers index usage, evaluates join orders, and selects an execution plan. For workloads with diverse, unpredictable query patterns involving complex joins, this is the right approach.</p>
<p>For time-series workloads, query shapes are highly repetitive. The same <code>WHERE ts &gt; now() - interval '...'</code> filter runs thousands of times per second. The full planning cycle executes every time. At high query rates, planning overhead is measurable in <code>pg_stat_statements</code> as the gap between <code>total_plan_time</code> and <code>total_exec_time</code>.</p>
<p>Statistics maintenance creates its own cost. ANALYZE samples rows to populate <code>pg_statistic</code>, with the sample size scaled by <code>default_statistics_target</code> (default: 100, which yields roughly 30,000 sampled rows). On billion-row tables, even this sampling-based statistics collection is expensive and must run frequently to keep estimates accurate. Stale statistics provide poor cardinality estimates, leading the planner to choose sequential scans over index scans, or vice versa.</p>
<p>With hundreds of partitions, the planner must evaluate partition pruning for each partition's bounds against the query predicates. This is fast per-partition but scales linearly with partition count. At 500+ partitions, plan time for simple queries can exceed execution time.</p>
<h3 id="write-ahead-logging-wal-volume">Write-Ahead Logging (WAL) volume</h3>
<p>Every data modification generates a WAL record before it's applied to the heap or index pages. WAL writes are sequential and synchronous (fsync per commit, or per <code>wal_writer_delay</code> interval with asynchronous commit). At 100K inserts/sec, WAL generation is roughly:</p>
<ul>
<li>Heap insert records: ~100–150 bytes each = 10–15MB/sec</li>
<li>Index insert records: 5 indexes × ~60–80 bytes each = 30–40MB/sec</li>
<li>Full-page writes (after checkpoint): intermittent bursts of 8KB per dirtied page</li>
</ul>
<p>Total sustained WAL throughput: 50–100MB/sec under normal operation, spiking higher after checkpoints when <code>full_page_writes</code> triggers 8KB records for newly dirtied pages. <a href="https://www.postgresql.org/docs/current/wal-reliability.html">The PostgreSQL documentation</a> describes why: "the first modification of a data page after each checkpoint results in logging the entire page content." At those rates, that's 3–6GB/min, 180–360GB/hour.</p>
<p>WAL I/O becomes a direct throughput bottleneck. <code>pg_stat_wal</code> shows <code>wal_write</code> and <code>wal_sync</code> times climbing. Replicas that can't apply WAL fast enough fall behind, and unprocessed WAL files accumulate on the primary's <code>pg_wal</code> directory, consuming disk. <code>max_wal_size</code> and checkpoint frequency become critical tuning parameters.</p>
<h3 id="the-compounding-effect">The compounding effect</h3>
<p>None of these four constraints operates in isolation. Each amplifies the others, and that's where the math gets ugly.</p>
<p>MVCC overhead creates per-tuple bloat, which accumulates faster than autovacuum can clean at high insert rates. Autovacuum competing for I/O degrades write throughput. Degraded write throughput causes queries on bloated tables to slow down, which increases pressure to add more indexes. More indexes produce more write amplification, more WAL, and more replication lag. Row storage forces read amplification on time-range queries, which creates pressure to add covering indexes. Those indexes add to the write overhead feeding back into the MVCC/autovacuum loop.</p>
<p><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/02/diagram-5-updated.png" alt="" loading="lazy"></p>
<p>At 50K inserts/sec with five indexes on a table, the steady-state database workload is: 50K heap tuple writes/sec, 250K B-tree index insertions/sec, 50–100MB/sec sustained WAL generation, continuous autovacuum activity across active partitions, and full query planning on every incoming query.</p>
<p>This is why a 16-core/64GB instance struggles with what appears to be a straightforward append-only workload.</p>
<p>Partitioning reduces per-partition table size but doesn't change the per-row overhead. Adding RAM improves buffer cache hit rates but doesn't reduce write amplification. Autovacuum tuning manages bloat but can't eliminate the cost of producing it. Each optimization operates within these constraints. None removes the constraints themselves.</p>
<p>This is the Optimization Treadmill at the mechanical level. You're not fighting configuration. You're fighting the storage model, the concurrency architecture, and the write path. All of which are designed for a workload that looks nothing like yours.</p>
<h2 id="when-to-choose-a-different-path">When to Choose a Different Path</h2>
<p>Most teams recognize this pattern 12–18 months too late. By then, the tables are massive, the partitioning scheme is deeply embedded, and migration has become a multi-month project. The difference between acting at 10M rows and acting at 1B rows is roughly an order of magnitude in engineering cost.</p>
<h3 id="postgres-workload-scoring-framework">Postgres Workload Scoring Framework</h3>
<p>Go back to <a href="#what-this-workload-looks-like">the six characteristics</a>. Be honest about how many describe your system right now, and then score yourself again against where you'll be in 12-18 months.</p>
<p>If four or five apply, you're in this pattern. The optimization phases above are already in your future, or you've started them.</p>
<p>If all six apply, you're past the point of easy exits. Architectural friction is the dominant factor in your performance trajectory, and the migration cost is climbing every quarter you wait.</p>
<p>If three or fewer apply, you likely have a different problem. Standard Postgres optimization should change the trajectory.</p>
<h3 id="early-warning-signs">Early warning signs</h3>
<p>Before the pattern becomes a crisis, it shows up in how the team spends its time:</p>
<p><strong>Optimization dominates planning.</strong> 10–20% of engineering time goes to database performance, and every quarterly roadmap includes a scalability line item.</p>
<p><strong>Costs grow faster than revenue.</strong> Finance is asking why the database bill increased 40% while user growth was only 15%.</p>
<p><strong>Operational complexity accumulates.</strong> 20+ pages of runbooks, partition management scripts, monitoring for autovacuum lag, replication delay, and index bloat. New engineers need weeks of onboarding before they can safely operate the database.</p>
<p><strong>Growth outpaces optimization.</strong> You ship a 2x improvement and data growth erases it within two quarters.</p>
<p><strong>Autovacuum is a constant concern.</strong> It's in the top five processes by CPU and I/O at all hours, and tuning it is a recurring conversation.</p>
<p>Two or three of these signs mean you should be paying attention. Four or more means you're already in the pattern.</p>
<h3 id="migration-complexity-at-different-scales">Migration complexity at different scales</h3>
<p><strong>10M–50M rows.</strong> A day or two to 1–2 weeks. Simple dump/restore, or logical replication. Low risk, fast validation, easy rollback. 1–2 engineers part-time (roughly 80 engineer-hours).</p>
<p><strong>100M–500M rows.</strong> 2–6 weeks. Partition-by-partition migration. More dependencies to account for, more thorough testing required. 2–3 engineers, mostly full-time (roughly 400 engineer-hours).</p>
<p><strong>1B+ rows.</strong> 2–6 months. Hundreds or thousands of partitions. Zero-downtime required, complex rollback planning. Application-level dual-write or change-data-capture pipelines are in play. 3–5 engineers full-time plus a validation period (roughly 2,000 engineer-hours).</p>
<p>Those hours are not spent on product features. And there's no point on this curve where migration gets easier by waiting.</p>
<h3 id="what-purpose-built-postgres-variants-means">What "purpose-built Postgres variants" means</h3>
<p>TimescaleDB is built on top of Postgres, not in place of it. The PostgreSQL wire protocol, SQL query language, extensions like PostGIS and pgvector, your application code, and your ecosystem tooling all stay the same. What changes is the storage engine and execution layer underneath.</p>
<p><strong>MVCC overhead addressed through columnar compression.</strong> The problem: every row insert in vanilla Postgres generates per-tuple MVCC headers, index entries, and WAL records regardless of whether the data will ever be updated, driving 3–5x write amplification and continuous autovacuum load. TimescaleDB's columnar storage (the <code>Columnstore</code> layer) batches up to 1,000 row versions per column into compressed arrays before writing to disk. Each batch write replaces thousands of individual heap tuple insertions with a single compressed segment write. The per-tuple MVCC header overhead is amortized across the batch, and autovacuum pressure drops proportionally. Far less row-level churn to clean up. In practice, write amplification drops from the 3–5x range to near 1:1 for sustained append workloads. The <a href="https://www.tigerdata.com/docs/about/latest/whitepaper">Tiger Data architecture whitepaper</a> covers the columnar layout and compression pipeline in detail.</p>
<p><strong>Row storage replaced by columnar layout for <a href="https://www.tigerdata.com/learn/the-best-time-series-databases-compared">time-series data</a>.</strong> The problem: vanilla Postgres reads all columns of every matching row even when a query needs two, creating 15x+ read amplification on wide tables, with none of the compression techniques applicable to time-series data. Rather than reading all 30 columns of a row to get two values, queries against the columnar layer read only the referenced columns from compressed column arrays. The 15x read amplification drops to near 1:1. Time-series compression (delta-of-delta for timestamps, gorilla-style XOR for floats, run-length encoding for repeated values) routinely achieves 10–20x compression ratios vs. heap storage. A dataset that occupies 1TB in vanilla Postgres often fits in 50–100GB with columnar compression enabled.</p>
<p><strong>Query planning overhead reduced through chunk exclusion and continuous aggregates.</strong> The problem: the Postgres planner runs a full optimization pass on every query, and with hundreds of partitions, partition pruning overhead can exceed execution time for simple queries. TimescaleDB's planner extension adds chunk exclusion that operates at a lower level than Postgres's partition pruning. Chunks are indexed by time range in a catalog table, and the planner excludes non-overlapping chunks before the standard planning phase. For query shapes that repeat thousands of times per second, this eliminates most of the per-partition pruning overhead. Continuous aggregates go further: pre-computed rollups stored as materialized views, updated incrementally as new data arrives, so dashboards querying hourly or daily aggregations hit a small summary table instead of scanning billions of raw rows.</p>
<p><strong>WAL volume reduced through batched ingestion.</strong> The problem: at 100K inserts/sec, vanilla Postgres generates 50–100MB/sec of WAL, creating I/O bottlenecks and causing replicas to fall behind. Lagging replicas force the primary to retain more unprocessed WAL, which consumes disk and makes the lag worse. The root cause is per-row WAL records: one per heap insert, one per index insertion. Columnar storage's batch writes generate WAL at the segment level rather than the row level. At 100K inserts/sec, WAL volume drops from 50–100MB/sec to roughly 5–15MB/sec in typical deployments, which eliminates most replication lag issues. Replicas that previously fell behind during write peaks can keep up without tuning.</p>
<p><img src="https://blog-cms.tigerdata.com/blog/content/images/2026/02/comparison-chart.png" alt="" loading="lazy"></p>
<p><strong>Concrete numbers.</strong> Benchmark results vary by workload, but the directional data is consistent: ingestion throughput 10–20x higher than vanilla Postgres at equivalent instance size, query performance on time-range aggregations 100x+ faster with columnar storage, storage footprint 10–20x smaller with compression enabled. RTABench, a benchmark for real-time analytics workloads, publishes results showing the performance gap between vanilla PostgreSQL and TimescaleDB across real-world query patterns. <a href="https://rtabench.com/">See the benchmark results</a></p>
<h3 id="decision-framework">Decision framework</h3>
<p>Choose a specialized architecture if you score 4+ on the <a href="#when-to-choose-a-different-path">Postgres Workload Scoring Framework</a> AND you're experiencing 2+ early warning signs AND you can project continued data growth.</p>
<p>Strong indicators to act now: you're under 100M rows, you're already building custom partitioning, your team spends 15%+ of engineering time on database optimization, and you can project 500M+ rows within 12 months.</p>
<p>You might not need this if writes are bursty rather than continuous, retention is 7–30 days, queries don't predominantly filter on time ranges, or growth is stable and slow.</p>
<h2 id="optimization-vs-architecture">Optimization vs. Architecture</h2>
<p>There are two different problems that both show up as "database performance is degrading."</p>
<p><strong>Problem 1: Optimization within the right architecture.</strong> The workload fits the database's design. Better indexes, query rewrites, configuration tuning, and hardware upgrades directly improve the trajectory. Postgres expertise solves the problem. For most workloads, vanilla Postgres is the right choice.</p>
<p><strong>Problem 2: The Optimization Treadmill.</strong> The workload hits design tradeoffs baked into the storage engine, concurrency model, and query planner. Optimization helps in the short term but doesn't change the trajectory. Each phase buys time. None buys a different outcome. You're working around the architecture rather than with it.</p>
<p>Knowing which problem you have determines the path forward.</p>
<p>If you followed the optimization phases in this piece, you weren't doing anything wrong. Those were correct responses to the symptoms. Any experienced Postgres team would have done the same. The pattern is common precisely because the progression makes sense at each step.</p>
<p>What changes with recognition is agency. At 10M–50M rows, you can choose a purpose-built architecture in days to weeks and redirect engineering time to product work. At 100M–500M rows, migration is harder but still reasonable, taking 2–6 weeks. At 1B+, it's a multi-month project, and every quarter of delay adds complexity.</p>
<p>The broader principle applies beyond this workload. Different databases have different architectural strengths, so the best choice depends on the workload. Postgres is brilliant for general-purpose relational work. Specialized variants built on top of Postgres excel at specialized patterns. Recognizing when architecture matters more than optimization is an engineering judgment call, not a criticism of the tool.</p>
<p>Architectural fit determines your ceiling. Optimization determines where you operate relative to that ceiling. When you're hitting the ceiling repeatedly, the productive question isn't "how do we optimize better?" It's "are we operating within the right architecture?" With this workload pattern, the ceiling was always there. You just needed enough data volume to find it. Score your workload. If you're at 8+ and under 100M rows, this is the cheapest architectural decision you'll make this year. <a href="https://www.tigerdata.com/docs/about/latest/whitepaper">The whitepaper</a> covers the mechanics. The <a href="https://console.cloud.timescale.com/signup">Tiger Data free trial</a> lets you validate on your own data.</p>
<p><a href="https://blog-cms.tigerdata.com/blog/from-4-databases-to-1-how-plexigrid-replaced-influxdb-got-350x-faster-queries-tiger-data/" rel="noreferrer">New: Learn how Plexigrid moved from 4 databases to 1 with Tiger Data.</a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[Six Signs That Postgres Tuning Won't Fix Your Performance Problems]]></title>
            <description><![CDATA[When Postgres tuning won't fix performance: recognize the six characteristics of time-series workloads that need TimescaleDB's purpose-built architecture.]]></description>
            <link>https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/six-signs-postgres-tuning-wont-fix-performance-problems</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Thu, 12 Feb 2026 21:26:14 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2026/02/Postgres-Tuning-Performance-compressed.png">
            </media:content>
            <content:encoded><![CDATA[<p>You've added indexes. You've partitioned tables. You've tuned autovacuum within an inch of its life. Performance improves for a few months, and then the dashboards go red again. Sound familiar?</p><p>If so, you're probably not doing anything wrong. You're running a workload that vanilla Postgres was never designed for, and no amount of configuration will change that.</p><p>It's not transactional. It's not a data warehouse. It's analytics on live data: high-frequency ingestion that stays operationally queryable. If you're running this pattern, you've already been through the cycle: add indexes, partition tables, tune autovacuum, upgrade instances. Each fix buys a few months. Then the metrics climb again.</p><p><strong>The longer you wait, the harder the migration. At 10M rows it takes days. At 500M rows, weeks. At 1B+, months.</strong> Recognizing the pattern early is the highest-leverage decision you can make.</p><p>This post describes six characteristics that define this workload. If four or five apply to your system, the friction is architectural, not operational. (For a deeper look at how <a href="https://www.tigerdata.com/learn/the-best-time-series-databases-compared" rel="noreferrer">purpose-built time-series architecture</a> addresses these constraints, see the <a href="https://www.tigerdata.com/docs/about/latest/whitepaper"><u>Tiger Data architecture whitepaper</u></a>.)</p><h2 id="continuous-high-frequency-ingestion">Continuous High-Frequency Ingestion</h2><p>The database is absorbing thousands to hundreds of thousands of inserts per second. Not in bursts. Not during a nightly ETL window. Continuously, 24/7.</p><p>Consider a semiconductor fab with 8,000 CNC machines and inspection stations on the floor, each reporting vibration, temperature, spindle speed, and tool wear every 2 seconds. That's 4,000 inserts/sec from a single facility. Add process control events, quality inspection results, and environmental monitoring across three plants, and you're at 30-50K inserts/sec before accounting for growth.</p><pre><code class="language-SQL">-- What a single station's insert stream looks like
INSERT INTO machine_telemetry (ts, station_id, metric, value)
VALUES
  (now(), 'CNC-4401', 'vibration_mm_s', 2.34),
  (now(), 'CNC-4401', 'spindle_rpm', 12045),
  (now(), 'CNC-4401', 'coolant_temp_c', 31.2),
  (now(), 'CNC-4401', 'tool_wear_pct', 67.8);
-- Multiply by 8,000 stations × 0.5 Hz × 3 facilities
</code></pre><p>This matters because Postgres needs breathing room to run maintenance. Autovacuum, index maintenance, statistics collection. Continuous ingestion means maintenance always competes with writes. There is no off-peak window.</p><h2 id="queries-revolve-around-time">Queries Revolve Around Time</h2><p>Nearly every row has a timestamp, and nearly every query filters on a time range. Last 30 minutes. This week versus last week. Everything between two dates.</p><p>A trading platform captures every order, fill, and cancellation across multiple venues. The operations team monitors execution quality in real time. The compliance team audits historical patterns. Both teams write queries that look like this:</p><pre><code class="language-SQL">-- Operations: real-time execution quality
SELECT venue, avg(fill_latency_us), percentile_cont(0.99)
  WITHIN GROUP (ORDER BY fill_latency_us)
FROM executions
WHERE ts &gt; now() - interval '15 minutes'
GROUP BY venue;

-- Compliance: historical pattern detection
SELECT account_id, count(*) as cancel_count
FROM order_events
WHERE ts BETWEEN '2025-01-01' AND '2025-03-31'
  AND event_type = 'cancel'
  AND cancel_reason = 'client_requested'
GROUP BY account_id
HAVING count(*) &gt; 500;
</code></pre><p>Time is the primary axis for both storage and retrieval. General-purpose B-tree indexes aren't built for this access pattern, which is why teams end up building manual partitioning schemes and custom tooling to get time-range queries to perform.</p><h2 id="data-is-append-only">Data Is Append-Only</h2><p>Once a row lands, it doesn't change. Sensor readings are immutable. Financial transactions don't get updated. Log entries are permanent. When data gets removed, it happens in bulk: drop an entire month's partition, not individual rows.</p><p>A wind farm operator collects turbine performance data: blade pitch, rotor speed, power output, nacelle orientation. Once recorded, these readings are facts. They never get corrected or overwritten.</p><pre><code class="language-SQL">-- This is the entire write pattern. INSERT. No UPDATE. No single-row DELETE.
INSERT INTO turbine_readings
  (ts, turbine_id, blade_pitch_deg, rotor_rpm, power_kw, wind_speed_ms)
VALUES
  (now(), 'WT-112', 12.4, 14.2, 2840, 11.3);

-- Data removal is always bulk
DROP TABLE turbine_readings_2023_q1;</code></pre><p><strong>Every row you insert carries 23 bytes of MVCC transaction metadata, on data you will never update.</strong> Autovacuum scans these tables constantly, cleaning up dead tuples that were never created through updates. At 50K inserts/sec, that's MVCC overhead on 4.3 billion rows per day that will never be modified. You're paying the full cost of a concurrency model designed for workloads that look nothing like yours.</p><h2 id="retention-is-measured-in-months-or-years">Retention Is Measured in Months or Years</h2><p>Seven years of financial records for compliance. Quarters of manufacturing data for root cause analysis. Two-plus years of training data for ML pipelines.</p><p>A pharmaceutical manufacturer tracks environmental conditions (temperature, humidity, particulate count) across cleanroom facilities to meet FDA 21 CFR Part 11 requirements. When a batch fails quality control six months after production, the investigation pulls environmental data from the exact time window the batch was in each room.</p><pre><code class="language-SQL">-- Root cause investigation: what were cleanroom conditions
-- during a batch produced 6 months ago?
SELECT room_id, avg(temp_c), max(particulate_count),
  bool_or(humidity_pct &gt; 45) as humidity_excursion
FROM cleanroom_environment
WHERE ts BETWEEN '2025-08-14 06:00' AND '2025-08-14 18:00'
  AND facility = 'building_3'
GROUP BY room_id;
</code></pre><p>Short retention hides architectural problems because old data ages out. Long retention removes that escape valve. At 50K inserts per second, that's 1.5 billion rows per year. After three years: 4.5 billion rows.</p><h2 id="queries-are-latency-sensitive">Queries Are Latency-Sensitive</h2><p>This data isn't sitting in cold storage waiting for a weekly report. It's being queried actively, under latency constraints.</p><p>A SaaS observability platform collects metrics from thousands of customer deployments. The product serves real-time dashboards, automated alerting, and deep-dive investigation, all from the same database. Latency expectations form a gradient:</p><pre><code class="language-SQL">-- Dashboard widget: last 5 minutes, needs &lt; 100ms response
SELECT host_id, avg(cpu_pct), max(mem_used_bytes)
FROM host_metrics
WHERE ts &gt; now() - interval '5 minutes'
  AND customer_id = 'cust_8821'
GROUP BY host_id;

-- Alert evaluation: last hour, needs &lt; 500ms
SELECT host_id, avg(cpu_pct)
FROM host_metrics
WHERE ts &gt; now() - interval '1 hour'
  AND customer_id = 'cust_8821'
GROUP BY host_id
HAVING avg(cpu_pct) &gt; 90;

-- Incident investigation: last 3 months, seconds acceptable
SELECT date_trunc('hour', ts), avg(cpu_pct), avg(mem_used_bytes)
FROM host_metrics
WHERE ts &gt; now() - interval '90 days'
  AND host_id = 'host-a3f9c'
GROUP BY 1 ORDER BY 1;
</code></pre><p>Data warehouse scope with operational latency requirements. All from a single system.</p><h2 id="growth-is-sustained">Growth Is Sustained</h2><p>Data volume growing 50-100%+ year over year on a predictable curve. Static workloads can be over-provisioned once and left alone. Growing workloads demand constant re-optimization.</p><p>A logistics company tracks GPS position, engine diagnostics, and cargo conditions across a fleet of refrigerated trucks. They started with 200 trucks. Expansion added 150 trucks in year one, another 300 in year two. Each truck reports every 10 seconds.</p><pre><code class="language-markdown">Year 1:  200 trucks ×  6 readings/min × 1,440 min/day = 1.7M rows/day
Year 2:  350 trucks ×  6 readings/min × 1,440 min/day = 3.0M rows/day
Year 3:  650 trucks ×  6 readings/min × 1,440 min/day = 5.6M rows/day

Cumulative after 3 years: ~3.8 billion rows
</code></pre><p>Every optimization you ship today is solving for a table size you'll blow past in six months. The treadmill doesn't stop.</p><h2 id="what-to-do-with-this">What to Do With This</h2><p>Count how many of these characteristics describe your system. If it's two or three, standard <a href="https://blog-cms.tigerdata.com/blog/postgres-optimization-treadmill/" rel="noreferrer">Postgres optimization</a> should have a real impact. The architecture fits your workload. Better indexes, smarter queries, autovacuum tuning. The usual playbook works.</p><p>If it's four or five, however, the friction is architectural, not operational. You don't need to abandon Postgres. Tiger Data extends vanilla Postgres to handle exactly this workload. You keep SQL, your extensions, your team's expertise, and the entire Postgres ecosystem. What changes is the storage engine, partitioning, and query planning underneath.</p><p>The numbers bear this out. In <a href="https://www.tigerdata.com/blog/postgresql-timescaledb-1000x-faster-queries-90-data-compression-and-much-more"><u>benchmarks against vanilla PostgreSQL at one billion rows</u></a>, TimescaleDB delivered up to 1,000x faster query performance while reducing storage by 90% through native compression. Ingest throughput stays constant past 10 billion rows, while PostgreSQL's performance degrades as indexed tables outgrow memory (throughput that starts at 100K+ rows/sec can crash to hundreds). On Azure infrastructure running <a href="https://www.tigerdata.com/blog/benchmark-results-fastest-time-series-database-azure"><u>RTABench workloads</u></a>, Tiger Cloud was 1,200x faster than vanilla PostgreSQL across 40 real-time analytics queries. These aren't synthetic edge cases. They're the exact query patterns this post describes: time-range filters, aggregations, selective scans on growing datasets.</p><p><em>This post is part of a series on Postgres performance limits for </em><a href="https://www.tigerdata.com/learn/time-series-database-what-it-is-how-it-works-and-when-you-need-one" rel="noreferrer"><em>high-frequency data workloads</em></a><em>. The full analysis, including a workload scoring framework and migration complexity breakdown at different scales, is in the anchor essay:</em><a href="https://www.tigerdata.com/blog/postgres-optimization-treadmill" rel="noreferrer"><em> <u>Understanding Postgres Performance Limits for Analytics on Live Data</u></em></a><em>. Ready to test it on your own data?</em><a href="https://console.cloud.timescale.com/signup"><em> <u>Start a free Tiger Data trial.</u></em></a></p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Train Your Agent to Be a Postgres Expert]]></title>
            <description><![CDATA[Turn AI into a Postgres expert with our MCP server. Get 35 years of best practices, versioned docs, and prompt templates for production-ready schemas.]]></description>
            <link>https://www.tigerdata.com/blog/free-postgres-mcp-prompt-templates</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/free-postgres-mcp-prompt-templates</guid>
            <category><![CDATA[Announcements & Releases]]></category>
            <category><![CDATA[AI]]></category>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Matty Stratton]]></dc:creator>
            <pubDate>Wed, 22 Oct 2025 14:02:12 GMT</pubDate>
            <media:content medium="image" href="https://blog-cms.tigerdata.com/blog/content/images/2025/10/2025-Oct-21-Prompt-Template-Thumbnail.png">
            </media:content>
            <content:encoded><![CDATA[<h3 id="with-prompt-templates-and-versioned-docs-we-turn-35-years-of-postgres-wisdom-into-structured-knowledge-your-agent-can-reason-with">With prompt templates and versioned docs, we turn 35 years of Postgres wisdom into structured knowledge your Agent can reason with.</h3><p>Agents are the <a href="https://www.tigerdata.com/blog/postgres-for-agents" rel="noreferrer">new developer</a>. But they’re generalists.&nbsp;</p><p>What happens when they design your Postgres database? Your schema runs, your tests pass… and six months later your queries crawl and your costs skyrocket.&nbsp;</p><p>AI-generated SQL and database schemas are almost right. And that’s the problem. Fixing schema design mistakes is costlier than refactoring code. It often means multi-week migrations, downtime windows, rollback plans, and your CEO asking why the site is in maintenance mode. The root issue? LLMs don’t have the depth of Postgres and database expertise to let them build scalable systems. And when agents try to learn, they find documentation written for humans, not for them.&nbsp;</p><p>But agents don’t need more data, they need better context. They need to know what “good Postgres” actually looks like. The good news is given the right context and tools, agents can become instant experts. Even with Postgres.&nbsp;</p><p>That’s why we built an MCP server that provides 35 years of Postgres wisdom, and full access Postgres docs, all in a format that agents can easily process.&nbsp;</p><p>And we think this just might be the best database MCP server ever built. While Neon, Supabase, and other Postgres companies created MCP servers as thin API wrappers, ours teaches AI how to think in Postgres. The Tiger MCP server gives AI tools that work automatically: no prompt engineering or manual lookups needed. You just ask. And it provides correct, idiomatic Postgres. </p><p>Our new MCP server ships with detailed prompt templates written by our senior engineers, plus versioned Postgres (15-18) docs, and <a href="https://docs.tigerdata.com/" rel="noreferrer">TimescaleDB docs</a>.&nbsp;When your AI needs to design a schema, it automatically pulls the right template and searches the docs (using hybrid search) to generate code that actually lasts. And of course, it handles the basics: start, stop, <a href="https://www.tigerdata.com/blog/fast-zero-copy-database-forks" rel="noreferrer">fork a database</a>. </p><p>But the real magic happens when you ask AI to design your schema. So let’s see what happens when AI designs a simple schema. First without the MCP server, then with it.</p><h2 id="without-the-mcp-server">Without the MCP Server</h2><pre><code class="language-SQL">CREATE TABLE customers (
    customer_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    phone VARCHAR(20),
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    is_active BOOLEAN DEFAULT TRUE
);

CREATE INDEX idx_customers_email ON customers(email);

CREATE TABLE authors (
    author_id SERIAL PRIMARY KEY,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    biography TEXT,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
</code></pre><p>What’s wrong here:&nbsp;</p><ul><li>Mixed ID types (SERIAL and UUID) with no clear reason</li><li>Arbitrary VARCHAR limits instead of TEXT</li><li>Case-sensitive email index → duplicate accounts</li><li>CURRENT_TIMESTAMP instead of now(), leading to inconsistent timestamps</li></ul><p>Someone new to Postgres may not notice these problems. Until it’s too late.</p><p>Now let’s see what AI generates with our new MCP server.</p><h2 id="with-the-mcp-server">With the MCP Server</h2><pre><code class="language-SQL">CREATE TABLE authors (
  author_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name TEXT NOT NULL,
  bio TEXT,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX ON authors (name);

CREATE TABLE users (
  user_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  first_name TEXT NOT NULL,
  last_name TEXT NOT NULL,
  phone TEXT,
  is_active BOOLEAN NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX ON users (LOWER(email));
CREATE INDEX ON users (created_at);</code></pre><p>What’s better about this?</p><ul><li>Consistent ID strategy with BIGINT GENERATED ALWAYS AS IDENTITY</li><li>TEXT instead of arbitrary VARCHAR limits</li><li>Case-insensitive email lookups</li><li>Modern timestamp handling</li></ul><p>But why does this matter?</p><p>Each of these differences creates a compounding problem. Changing datatypes in the future will require full table rewrites. Missing lowercase email handling means duplicate accounts and confused users. And time zones? Every senior developer gets the thousand-yard stare when you mention UTC conversions.</p><p>This is just with a small example; imagine what would happen with more complex schemas.</p><p>And if you don’t believe us, here’s what Claude has to say:</p><pre><code class="language-markdown">&gt; Please describe the schema you would create for an e-commerce website two times, first with the tiger mcp server disabled, then with the tiger mcp server enabled. For each time, write the schema to its own file in the current working directory. Then compare the two files and let me know which approach generated the better schema, using both qualitative and quantitative reasons. For this example, only use standard Postgres.</code></pre><figure class="kg-card kg-video-card kg-width-regular" data-kg-thumbnail="https://blog-cms.tigerdata.com/blog/content/media/2025/10/how-to-train-your-agent_thumb.jpg" data-kg-custom-thumbnail="">
            <div class="kg-video-container">
                <video src="https://blog-cms.tigerdata.com/blog/content/media/2025/10/how-to-train-your-agent.mp4" poster="https://img.spacergif.org/v1/1280x720/0a/spacer.png" width="1280" height="720" loop="" autoplay="" muted="" playsinline="" preload="metadata" style="background: transparent url('https://blog-cms.tigerdata.com/blog/content/media/2025/10/how-to-train-your-agent_thumb.jpg') 50% 50% / cover no-repeat;"></video>
                <div class="kg-video-overlay">
                    <button class="kg-video-large-play-icon" aria-label="Play video">
                        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                            <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                        </svg>
                    </button>
                </div>
                <div class="kg-video-player-container kg-video-hide">
                    <div class="kg-video-player">
                        <button class="kg-video-play-icon" aria-label="Play video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M23.14 10.608 2.253.164A1.559 1.559 0 0 0 0 1.557v20.887a1.558 1.558 0 0 0 2.253 1.392L23.14 13.393a1.557 1.557 0 0 0 0-2.785Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-pause-icon kg-video-hide" aria-label="Pause video">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <rect x="3" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                                <rect x="14" y="1" width="7" height="22" rx="1.5" ry="1.5"></rect>
                            </svg>
                        </button>
                        <span class="kg-video-current-time">0:00</span>
                        <div class="kg-video-time">
                            /<span class="kg-video-duration">0:50</span>
                        </div>
                        <input type="range" class="kg-video-seek-slider" max="100" value="0">
                        <button class="kg-video-playback-rate" aria-label="Adjust playback speed">1×</button>
                        <button class="kg-video-unmute-icon" aria-label="Unmute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M15.189 2.021a9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h1.794a.249.249 0 0 1 .221.133 9.73 9.73 0 0 0 7.924 4.85h.06a1 1 0 0 0 1-1V3.02a1 1 0 0 0-1.06-.998Z"></path>
                            </svg>
                        </button>
                        <button class="kg-video-mute-icon kg-video-hide" aria-label="Mute">
                            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                                <path d="M16.177 4.3a.248.248 0 0 0 .073-.176v-1.1a1 1 0 0 0-1.061-1 9.728 9.728 0 0 0-7.924 4.85.249.249 0 0 1-.221.133H5.25a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3h.114a.251.251 0 0 0 .177-.073ZM23.707 1.706A1 1 0 0 0 22.293.292l-22 22a1 1 0 0 0 0 1.414l.009.009a1 1 0 0 0 1.405-.009l6.63-6.631A.251.251 0 0 1 8.515 17a.245.245 0 0 1 .177.075 10.081 10.081 0 0 0 6.5 2.92 1 1 0 0 0 1.061-1V9.266a.247.247 0 0 1 .073-.176Z"></path>
                            </svg>
                        </button>
                        <input type="range" class="kg-video-volume-slider" max="100" value="100">
                    </div>
                </div>
            </div>
            
        </figure><pre><code class="language-markdown">Verdict:

The Tiger MCP-enabled schema is objectively superior because:

1. Type Safety: Uses modern PostgreSQL types that prevent common errors
2. Data Integrity: 4x more constraints ensure data quality at the database level
3. Performance: Better indexing strategy with 55% more indexes, including partial and expression indexes
4. Maintainability: Better documentation, clear comments, proper naming
5. Features: More complete e-commerce functionality (discounts, full-text search, audit trails)
6. PostgreSQL Best Practices: Follows official PG17 recommendations from the documentation
7. Future-Proof: Uses modern features like GENERATED ALWAYS AS IDENTITY and NULLS NOT DISTINCT

The Tiger MCP server provided access to PostgreSQL-specific best practices documentation and design patterns that resulted in a more robust, performant, and maintainable schema.
</code></pre><h2 id="how-prompt-templates-make-it-all-possible">How Prompt Templates Make It All Possible</h2><p>Behind the scenes, AI uses the MCP server to call <code>get_prompt_template(‘design_postgres’)</code> to load schema design guidance. No pasted docs. No corrections. Just better code.</p><p>Prompt templates turn production wisdom into reusable guidance for AI. Instead of hunting through documentation written for humans, AI gets the information it needs in a format built for machines.</p><p>It comes down to the fact that humans and LLMs have opposite needs. Humans need narratives and memorable examples (and yes, even cat memes) to help them retain information. LLMs need to preserve context window space. That’s why prompt templates make terrible blog posts, but perfect AI guidance.</p><p>Our philosophy is: don't re-teach what the model already knows. LLMs have seen millions of lines of SQL. They know how to write CREATE TABLE. What they don’t know is the 35 years of Postgres wisdom about what works well and what doesn’t.</p><p>It's like your senior DBA whispering advice in the model's ear.</p><p>Our schema design template <code>(design_postgres_tables)</code> doesn’t explain what a primary key is. It jumps straight to guidance:</p><p>“Prefer <code>BIGINT GENERATED ALWAYS AS IDENTITY</code>; use <code>UUID</code> only when global uniqueness is needed.”</p><p>For data types, it doesn’t teach from scratch. It just tells you what works:</p><p>“DO NOT use <code>money</code> type; DO use <code>numeric</code> instead.”</p><p>Here’s a real snippet from the template:</p><pre><code class="language-markdown">## Postgres "Gotchas"

- **FK indexes**: Postgres **does not** auto-index FK columns. Add them.
- **No silent coercions**: length/precision overflows error out (no truncation). 
  Example: inserting 999 into `NUMERIC(2,0)` fails with error, unlike some 
  databases that silently truncate or round.
- **Heap storage**: no clustered PK by default (unlike SQL Server/MySQL InnoDB); 
  row order on disk is insertion order unless explicitly clustered.</code></pre><p>These gotchas trip up LLMs the same way they trip up developers new to Postgres. We optimized these templates for machines: short, factual, and precise, packing maximum guidance into minimum tokens.&nbsp;</p><p>We tested the same approach on a real IoT schema design task. Without templates, the AI added forbidden configurations and missed critical optimizations. <em>With</em> templates, it generated production-ready code with compression, continuous aggregates, and tuned performance.</p><p>That’s how prompt templates work. Now let’s see how the MCP server makes it all happen.</p><h2 id="how-this-mcp-server-is-smarter-than-others">How This MCP Server is Smarter Than Others</h2><p>While Neon, Supabase, and other Postgres companies created MCP servers as thin API wrappers, ours teaches AI how to think in Postgres.The Tiger MCP server gives AI tools that work automatically: no prompt engineering or manual lookups needed. You just ask. And it provides correct, idiomatic Postgres.</p><p><strong><code>get_prompt_template</code> provides auto-discovered expertise. </strong>Instead of having to call a template explicitly, you just say “I want to make a schema for IoT devices…” and the MCP server figures it out. </p><p>With self-discoverable templates, the AI can detect intent and load the right recipe, applying 35 years of Postgres best practices behind the scenes. </p><p><strong>The templates have real depth. </strong>No scraped snippets or boilerplate. The templates are written by senior Postgres engineers, and provide opinionated, production-tested guidance that is tuned to avoid every trap that seasoned DBAs know to avoid.</p><p><strong>Postgres-native vector retrieval adds the right context.</strong> When the AI needs more information, the MCP server searches the versioned Postgres (15-18) and TimescaleDB docs. And it uses Postgres itself for storage and vector search.</p><p>Versioning is critical. For example, Postgres 15 introduced UNIQUE NULLS NOT DISTINCT, while 16 improved parallel queries, and 17 changed COPY error handling. The MCP keeps AIs grounded in correct syntax every time, avoiding broken code from the wrong version.</p><p>The Tiger MCP doesn’t just wire up APIs. It teaches AI to think like a real Postgres engineer. </p><p>You don’t have to craft the perfect prompt. You just ask, and it does the right thing.</p><h2 id="see-it-for-yourself">See It For Yourself</h2><p>Install the Tiger CLI and MCP server:</p><pre><code class="language-shell">curl -fsSL https://cli.tigerdata.com | sh
tiger auth login
tiger mcp install</code></pre><p>(We also have alternative <a href="https://github.com/timescale/tiger-cli"><u>installation instructions</u></a> for the CLI tool.)</p><p>Then select your AI assistant (Claude Code, Cursor, VS Code, Windsurf, etc.) and immediately get real Postgres knowledge flowing into your AI.</p><p>This is how Postgres becomes the best database to use with AI coding tools: not by accident, not because someone pasted docs into a chat, but because the tooling now teaches AI how to think in Postgres.&nbsp;</p><p>Try the MCP server. Break it. <a href="https://timescaledb.slack.com/join/shared_invite/zt-38c4rrt9t-eR8I4hnb4qeGLUrL6hM3mA#/shared-invite/email"><u>Improve it</u></a>. Help us teach every AI to write real Postgres.</p><hr><p><strong>About the authors</strong></p><p><strong>Matty Stratton</strong></p><p>Matty Stratton is the Head of Developer Advocacy and Docs at Tiger Data, a well-known member of the DevOps community, founder and co-host of the popular <a href="https://www.arresteddevops.com/"><u>Arrested DevOps</u></a> podcast, and a global organizer of the <a href="https://devopsdays.org"><u>DevOpsDays</u></a> set of conferences.</p><p>Matty has over 20 years of experience in IT operations and is a sought-after speaker internationally, presenting at Agile, DevOps, and cloud engineering focused events worldwide. Demonstrating his keen insight into the changing landscape of technology, he recently changed his license plate from DEVOPS to KUBECTL.</p><p>He lives in the Chicagoland area and has three awesome kids and two Australian Shepherds, whom he loves just a little bit more than he loves Diet Coke.</p><p><strong>Matvey Arye</strong></p><p><a href="https://www.linkedin.com/in/matvey-arye/"><u>Matvey Arye</u></a> is a founding engineering leader at Tiger Data (creators of TimescaleDB), the premiere provider of relational database technology for time-series data and AI. Currently, he manages the team at Tiger Data responsible for building the go-to developer platform for AI applications.&nbsp;</p><p>Under his leadership, the Tiger Data engineering team has introduced partitioning, compression, and incremental materialized views for time-series data, plus cutting-edge indexing and performance innovations for AI.&nbsp;</p><p>Matvey earned a Bachelor degree in Engineering at The Cooper Union. He earned a Doctorate in Computer Science at Princeton University where his research focused on cross-continental data analysis covering issues such as networking, approximate algorithms, and performant data processing.&nbsp;</p><p><strong>Jacky Liang</strong></p><p><a href="https://www.linkedin.com/in/jjackyliang/"><u>Jacky Liang</u></a> is a developer advocate at Tiger Data with an AI and LLMs obsession. He's worked at Pinecone, Oracle Cloud, and Looker Data as both a software developer and product manager which has shaped the way he thinks about software.&nbsp;</p><p>He cuts through AI hype to focus on what actually works. How can we use AI to solve real problems? What tools are worth your time? How will this technology actually change how we work?&nbsp;</p><p>When he's not writing or speaking about AI, Jacky builds side projects and tries to keep up with the endless stream of new AI tools and research—an impossible task, but he keeps trying anyway. His model of choice is Claude Sonnet 4 and his favorite coding tool is Claude Code.</p><p></p>]]></content:encoded>
        </item>
    </channel>
</rss>