<?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>Tue, 07 Apr 2026 09:51:28 GMT</lastBuildDate>
        <atom:link href="https://www.tigerdata.com/blog" rel="self" type="application/rss+xml"/>
        <ttl>60</ttl>
        <item>
            <title><![CDATA[Creating a Fast Time-Series Graph With Postgres Materialized Views]]></title>
            <description><![CDATA[Build a time-series graph or plot to quickly visualize data using Postgres materialized views and their upgraded version, continuous aggregates.]]></description>
            <link>https://www.tigerdata.com/blog/creating-a-fast-time-series-graph-with-postgres-materialized-views</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/creating-a-fast-time-series-graph-with-postgres-materialized-views</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <category><![CDATA[Data Visualization]]></category>
            <dc:creator><![CDATA[Dylan Paulus]]></dc:creator>
            <pubDate>Mon, 27 Nov 2023 18:21:08 GMT</pubDate>
            <media:content medium="image" href="https://timescale.ghost.io/blog/content/images/2023/11/Creating-a-Fast-Time-Series-Graph-With-Postgres-Materialized-ViewsCreating-a-Fast-Time-Series-Graph-With-Postgres-Materialized-Views.jpg">
            </media:content>
            <content:encoded><![CDATA[<p>Imagine you have a massive amount of time-series data you want to explore and visualize. Seeing the latest trends, the historical patterns, and the outliers in your data can help you gain insights and make decisions. But how do you visualize and analyze time-series data effectively? How do you create graphs, plots, and other visualizations for real-time analytics showing the current state of your data and the historical changes over different time intervals? And how do you do it efficiently without sacrificing performance or accuracy?</p><p>In this article, we will see how to use PostgreSQL materialized views and Timescale’s improved version of these—continuous aggregates—to create a time-series graph that answers these questions.&nbsp;</p><div class="kg-card kg-callout-card kg-callout-card-purple"><div class="kg-callout-emoji">📊</div><div class="kg-callout-text"><a href="https://www.timescale.com/blog/what-is-a-time-series-plot-and-how-can-you-create-one/"><u>Learn more about time-series plots</u></a> or dive into <a href="https://www.timescale.com/blog/what-is-a-time-series-graph-with-examples/"><u>an explainer about time-series graphs</u></a>.</div></div><h2 id="creating-a-time-series-graph-in-postgresql">Creating a Time-Series Graph in PostgreSQL<br></h2><h3 id="method-1-creating-plots-and-graphs-directly-from-raw-data">Method 1: Creating plots and graphs directly from raw data&nbsp;</h3><p>Pretend you are a senior engineer at a company that creates devices to monitor the electrical power grid. These devices export a large amount of data—one PostgreSQL row is created per device every second. For this example, let's say the local power company uses one hundred devices (60,480,000 rows created per week). You want to be able to give your customers data visualizations of the <a href="https://en.wikipedia.org/wiki/Electrical_grid#:~:text=The%20demand%2C%20or%20load%20on,demand%20is%20the%20maximum%20load"><u>load on a given line</u></a> per hour, day, and week.</p><p>Our table looks like this:</p><pre><code class="language-sql">CREATE TABLE demand (
    id          serial primary key,
    amps        DOUBLE PRECISION  NOT NULL,
    location    TEXT,
    time        TIMESTAMPTZ       NOT NULL
);
</code></pre>
<p>We can import a single device's data by running the following INSERT command (or, you know, generate dummy data!). It will take some time to insert 10,540,800 rows; shorten the gap between timestamps to produce less data:</p><pre><code class="language-sql">INSERT INTO demand (amps, location, time) VALUES  (random()*40, 'Spokane, WA', 
generate_series('2023-09-01T00:00:00+03:00'::timestamptz, '2023-12-31T23:59:59+03:00'::timestamptz, '1 second'));
</code></pre>
<p>Now, we can generate a time-series plot to calculate average amps per minute with the following SQL. Change <code>'1 minute'</code> to <code>'1 day'</code> or <code>'1 week'</code> to create time-series plots for different intervals.</p><pre><code class="language-sql">SELECT 
 date_bin(interval '1 minute', time, timestamptz '2023-08-01' ) AS time_interval, 
 AVG(amps)
FROM demand
GROUP BY 1
ORDER BY 1;
</code></pre>
<figure class="kg-card kg-image-card"><img src="https://timescale.ghost.io/blog/content/images/2023/11/query-results.png" class="kg-image" alt="The query output" loading="lazy" width="1724" height="1422" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/11/query-results.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/11/query-results.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/11/query-results.png 1600w, https://timescale.ghost.io/blog/content/images/2023/11/query-results.png 1724w" sizes="(min-width: 720px) 720px"></figure><p></p><p>This query can take some time, depending on how much data is in the <code>demand</code> table. For the 10,540,800 rows we created, the query takes 15 seconds to execute on an 8-Core Intel Core i9 with 32&nbsp;GB RAM Apple MacBook Pro. That is 15 seconds to return plot data for a single device over three months! Imagine if we had hundreds or thousands of devices spanning over a year.</p><figure class="kg-card kg-image-card"><img src="https://timescale.ghost.io/blog/content/images/2023/11/explain-initial-query.png" class="kg-image" alt="The initial query" loading="lazy" width="2000" height="964" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/11/explain-initial-query.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/11/explain-initial-query.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/11/explain-initial-query.png 1600w, https://timescale.ghost.io/blog/content/images/2023/11/explain-initial-query.png 2252w" sizes="(min-width: 720px) 720px"></figure><p>Let's look at a few ways to improve the speed of our time-series plot using materialized views and continuous aggregates.</p><h3 id="method-2-using-materialized-views-to-make-graphs-more-performant">Method 2: Using materialized views to make graphs more performant&nbsp;</h3><p>In PostgreSQL, a view can be thought of as a stored query on top of a table. When we query a view, the underlying query the view was created with gets called. This gives us the ability to abstract away and simplify our queries, but a view won't do much to improve the speed of a query.&nbsp;</p><p>Somewhere between a table and a view sits the materialized view. A materialized view works similarly to a view in that you can make queries reusable. The difference is a materialized view will store the resulting data on disk—caching the data. When you use a materialized view, you don’t have to run the query again. You get the results from the disk. This makes your queries much faster!</p><div class="kg-card kg-callout-card kg-callout-card-purple"><div class="kg-callout-emoji">🗒️</div><div class="kg-callout-text"><a href="https://www.timescale.com/blog/how-postgresql-views-and-materialized-views-work-and-how-they-influenced-timescaledb-continuous-aggregates/"><u>Learn more about PostgreSQL materialized views and how they influenced the design of our continuous aggregates</u></a>.</div></div><p></p><p>To improve the speed of our time-series graph data, let's create a materialized view over the <code>demand</code> table.</p><pre><code class="language-sql">CREATE MATERIALIZED VIEW demand_amps_by_minute AS 
SELECT 
  date_bin(
    interval '1 minute', time, timestamptz '2023-08-01'
  ) AS time_interval, 
  AVG(amps) AS median 
FROM 
  demand 
GROUP BY 
  1 
ORDER BY 
  1;
</code></pre>
<p>Since creating the materialized view needs to run the same average amps per minute SQL, it can take some time to create. Once it's complete, run <code>SELECT * FROM demand_amps_by_minute;</code>. On my same MacBook Pro, the query now takes 58ms—much better!</p><figure class="kg-card kg-image-card"><img src="https://timescale.ghost.io/blog/content/images/2023/11/explain-materialized-view.png" class="kg-image" alt="The query plan with a materialized view" loading="lazy" width="2000" height="668" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/11/explain-materialized-view.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/11/explain-materialized-view.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/11/explain-materialized-view.png 1600w, https://timescale.ghost.io/blog/content/images/2023/11/explain-materialized-view.png 2252w" sizes="(min-width: 720px) 720px"></figure><p>This shows off the speed improvement materialized views can give us, but they come with a downside we haven't covered yet. When new data is added, updated, or deleted from the underlying table, we have to manually refresh the materialized view with a <code>REFRESH MATERIALIZED VIEW [materialized view name];</code> statement. This will completely replace the data in the materialized view with all the new data from the table using the query from the definition.</p><p>Having to refresh your materialized views comes with a few glaring problems:</p><ul><li>If you have a steady stream of data being written to your table, which is very common in time-series data, then once you refresh your materialized view, it'll be out of date.</li><li>Refreshing a materialized view comes with a performance hit, as it needs to rerun the materialized view's definition on all the data in the table to refresh itself.</li><li>You'll need to remember to manually run a refresh on your materialized views or maintain a cron job.</li></ul><p>However, Timescale has engineered a little magic under the hood to remove all these pain points of using materialized views through <a href="https://docs.timescale.com/use-timescale/latest/continuous-aggregates/"><u>continuous aggregates</u></a>.</p><h3 id="method-3-creating-graphs-that-are-more-resource-efficient-and-easier-to-maintain-via-continuous-aggregates">Method 3: Creating graphs that are more resource-efficient and easier to maintain via continuous aggregates</h3><p>Timescale’s continuous aggregates have the same look and feel as materialized views, but they add some essential functionality to help you keep your graphs, plots, dashboards, or other visualizations of real-time analytics performant over time without manual maintenance.&nbsp;</p><p>First, continuous aggregates stay automatically updated via a refresh policy defined by you—i.e., you can configure your continuous aggregate view so it gets updated automatically every 30 minutes, including your latest data. This is much more convenient than refreshing your views manually!&nbsp;</p><p>But the key is what happens under the hood once this refresh policy kicks in. In plain PostgreSQL materialized views, when you refresh the view, the query will be recomputed over the entire dataset. In other words, in plain PostgreSQL, materialized views’ refreshes are not incremental. This makes the refresh process computationally expensive unnecessarily, especially once your dataset grows and a large volume of data needs to be materialized.&nbsp;&nbsp;</p><p>Continuous aggregates fix this inefficiency: when you refresh a continuous aggregate,&nbsp; Timescale doesn’t drop all the old data and recompute the aggregate against it. Instead, the engine just runs the query against the most recent refresh period (e.g., 30 minutes) and the data that has changed since the last refresh. This way, continuous aggregates keep your visualizations performant over time, independently of how much your dataset is growing.&nbsp;<br></p><p>Switching over to Timescale, we'll recreate our <code>demand</code> table using the same <code>CREATE TABLE</code> statement as before but leaving off the <code>id</code> column (we'll use <code>time</code> instead).</p><pre><code class="language-sql">CREATE TABLE demand (
    amps        DOUBLE PRECISION  NOT NULL,
    location    TEXT,
    time        TIMESTAMPTZ       NOT NULL
);
</code></pre>
<p>Next, we'll update <code>demand</code> to be a <a href="https://www.tigerdata.com/blog/database-indexes-in-postgresql-and-timescale-cloud-your-questions-answered" rel="noreferrer">hypertable</a>:</p><pre><code class="language-sql">SELECT create_hypertable('demand', 'time');
</code></pre>
<p>Finally, populate the <code>demand</code> table with data:</p><pre><code class="language-sql">INSERT INTO demand (amps, location, time) VALUES  (random()*40, 'Spokane, WA', 
generate_series('2023-09-01T00:00:00+03:00'::timestamptz, '2023-12-31T23:59:59+03:00'::timestamptz, '1 second'));
</code></pre>
<p>At last we can create our continuous aggregate that will work similarly to the previous materialized view.</p><pre><code class="language-sql">CREATE MATERIALIZED VIEW demand_amps_by_minute
WITH (timescaledb.continuous) AS
SELECT 
   time_bucket(INTERVAL '1 minute', time) AS bucket,
   AVG(amps)
FROM demand
GROUP BY bucket;
</code></pre>
<p>We need to update its refresh policy to have our continuous aggregate continuously refresh. For this example, we'll have it refresh every minute. But for your own workloads, you'll need to optimize these settings to fit your needs.</p><pre><code class="language-sql">SELECT add_continuous_aggregate_policy(
	'demand_amps_by_minute', 
	start_offset =&gt; NULL, 
	end_offset =&gt; INTERVAL '1 h',
	schedule_interval =&gt; INTERVAL '1 m');
</code></pre>
<p>If we run a <code>SELECT</code> query on <code>demand_amps_by_minute</code>, I now get 120&nbsp;ms to query the continuous aggregate. A little bit slower than a raw materialized view, but we're still much faster than querying the table!&nbsp;</p><figure class="kg-card kg-image-card"><img src="https://timescale.ghost.io/blog/content/images/2023/11/explain-continous-aggregate.png" class="kg-image" alt="The query plan with a continuous aggregate" loading="lazy" width="2000" height="1023" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/11/explain-continous-aggregate.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/11/explain-continous-aggregate.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/11/explain-continous-aggregate.png 1600w, https://timescale.ghost.io/blog/content/images/size/w2400/2023/11/explain-continous-aggregate.png 2400w" sizes="(min-width: 720px) 720px"></figure><p>Continuous aggregates track which chunks have been materialized and what data hasn't been yet by using a watermark (e.g., a pointer). When you query a continuous aggregate, you get materialized data before the watermark and non-materialized data after the watermark. This watermark will move as the aggregate policy continues to work through materializing non-materialized data.</p><p>All this adds some time to the overall query speed, but we benefit from not having to manually refresh the materialized view!</p><p>Let's try it out. Insert a new row into the underlying <code>demand</code> table.</p><pre><code class="language-sql">INSERT INTO demand (amps, location, time) VALUES (100.2, 'Pullman, WA', now())
</code></pre>
<p>Then, if we re-query our continuous aggregate, we'll see the newly added row returned to us.</p><h2 id="start-speeding-up-your-queries-today">Start Speeding Up Your Queries Today</h2><p>Throughout this article, we discovered how to use a table to create a time-series graph for large amounts of data. We improved the query performance by taking advantage of PostgreSQL's materialized views.&nbsp;</p><p>However, materialized views can be time-consuming to maintain. Last, we removed the need to manually refresh the materialized view by taking advantage of Timescale's continuous aggregates. Now, it’s your turn to create your own time-series plots or <a href="https://www.timescale.com/learn/real-time-analytics-in-postgres"><u>real-time analytics</u></a> using these methods!&nbsp;</p><p>You can <a href="https://console.cloud.timescale.com/signup"><u>create a free Timescale account</u></a> and start speeding up your queries today.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Reduce Your PostgreSQL Database Size]]></title>
            <description><![CDATA[Shrinking the storage used by your PostgreSQL database will help keep your costs low and improve the performance of your large tables. ]]></description>
            <link>https://www.tigerdata.com/blog/how-to-reduce-your-postgresql-database-size</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/how-to-reduce-your-postgresql-database-size</guid>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Dylan Paulus]]></dc:creator>
            <pubDate>Fri, 06 Oct 2023 18:52:39 GMT</pubDate>
            <media:content medium="image" href="https://timescale.ghost.io/blog/content/images/2023/10/How-to-Reduce-Your-PostgreSQL-Database-Size.png">
            </media:content>
            <content:encoded><![CDATA[<p>Your phone buzzes in the middle of the night. You pick it up. A monitor went off at work—your PostgreSQL database is slowly but steadily reaching its maximum storage space. You are the engineer in charge. What should you do?</p><p>Okay, if it comes down to that situation, you should remedy it ASAP by adding more storage. But you’re going to need a better long-term strategy to optimize your PostgreSQL storage use, or you’ll keep paying more and more money.</p><p>Does your PostgreSQL database really need to be that large? Is there something you can do to optimize your storage use?</p><p>This article explores several strategies that will help you reduce your PostgreSQL database size considerably and sustainably.</p><h2 id="why-is-postgresql-storage-optimization-important">Why Is PostgreSQL Storage Optimization Important?</h2><p></p><p>Perhaps you’re thinking:</p><p>“Storage is cheap these days, and optimizing a PostgreSQL database takes time and effort. I’ll just keep adding more storage.”</p><p>Or perhaps:</p><p>“My PostgreSQL provider is actually usage-based (<a href="https://timescale.ghost.io/blog/savings-unlocked-why-we-switched-to-a-pay-for-what-you-store-database-storage-model/">like Timescale</a>), and I don’t have the problem of being locked into a large disk.”</p><p>Indeed, resigning yourself to simply using more storage is the most straightforward way to tackle an increasingly growing PostgreSQL database. Are you running servers on-prem? Slap another hard drive on that bad boy. Are you running PostgreSQL in RDS? Raise the storage limits. But this comes with problems.</p><p>The first and most obvious problem is the cost. For example, if you’re running PostgreSQL in an EBS instance in AWS or in <a href="https://timescale.ghost.io/blog/understanding-amazon-rds-cost/">RDS</a>, you’ll be charged on an allocation basis. This model assumes you’ll predetermine how much disk space you’ll need in the future and then pay for it, regardless of whether you end up using it or not, and without the chance of downscaling.</p><p>In other PostgreSQL providers, when you run out of storage space, you must upgrade and pay for the next available plan or storage tier, meaning you’ll see a considerably higher bill overnight.</p><p>In a way, these issues are mitigated by usage-based models. <a href="https://timescale.ghost.io/blog/savings-unlocked-why-we-switched-to-a-pay-for-what-you-store-database-storage-model/">Timescale</a> <a href="https://www.timescale.com/pricing">charges by the amount of storage you use</a>: you don't need to worry about allocating storage or managing storage plans, which really simplifies things—and the less storage you use, the less it costs. </p><p>Usage-based models are a great incentive to actually optimize your PostgreSQL database size as much as possible since you’ll see immediate reductions in your bill. But yes, this also works the opposite way: if you ignore managing your storage, your storage bill will go up.</p><p>The second problem with not optimizing your PostgreSQL storage usage is that this situation can lead to bad performance. Queries run slower and your I/O operations increase. This is something that often gets overlooked, <a href="https://www.timescale.com/learn/postgresql-performance-tuning-how-to-size-your-database">but maintaining PostgreSQL storage usage is paramount to keeping large PostgreSQL tables fast</a>.</p><p>‌‌This last point deserves a deeper dive into how data is actually stored in PostgreSQL and what is causing the problem, so let’s briefly cover some essential PostgreSQL storage concepts.</p><h2 id="essential-postgresql-storage-concepts%E2%80%8C%E2%80%8C%E2%80%8C%E2%80%8C">Essential PostgreSQL Storage Concepts‌‌‌‌</h2><p><br><strong>How does PostgreSQL store data?</strong></p><p>At a high level, there are two terms you need to understand: tuples and pages. </p><ul><li>A tuple is the physical representation of an entry in a table. You'll generally see the terms tuple and row used interchangeably. Each element in a tuple corresponds to a specific column in that table, containing the actual data value for that column.</li><li>A page is the unit of storage in PostgreSQL, typically 8&nbsp;kB in size, that holds one or more tuples. PostgreSQL reads and writes data in page units. </li></ul><p>Each page in PostgreSQL consists of a page header (which contains metadata about the page, such as page layout versions, page flags, and so on) and actual data (including tuples). There’s also a special area called the Line Pointer Array, which provides the offsets where each tuple begins.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.29.48-AM.png" class="kg-image" alt="A simple representation of a PostgreSQL page containing metadata about the page and tuples stored in the page" loading="lazy" width="1858" height="944" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/10/Screenshot-2023-10-06-at-11.29.48-AM.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/10/Screenshot-2023-10-06-at-11.29.48-AM.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/10/Screenshot-2023-10-06-at-11.29.48-AM.png 1600w, https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.29.48-AM.png 1858w" sizes="(min-width: 720px) 720px"><figcaption><i><em class="italic" style="white-space: pre-wrap;">A simple representation of a PostgreSQL page containing metadata about the page and tuples stored in the page</em></i></figcaption></figure><h3 id="what-happens-when-querying-data">What happens when querying data? </h3><p>When querying data, PostgreSQL utilizes the metadata to quickly navigate to the relevant page and tuple. The PostgreSQL query planner examines the metadata to decide the optimal path for retrieving data, for example, estimating the cost of different query paths based on the metadata information about the tables, indexes, and data distribution.</p><h3 id="what-happens-when-we-insert-delete-update-a-row-in-postgresql">What happens when we INSERT/ DELETE/ UPDATE a row in PostgreSQL?</h3><p>When a new tuple is inserted into a PostgreSQL table, it gets added to a page with enough free space to accommodate the tuple. Each tuple within a page is identified and accessed using the offset provided in the Line Pointer Array.</p><p>If a tuple inserted is too big for the available space of a page, PostgreSQL doesn't split it between two 8kB pages. Instead, it employs TOAST to compress and/or break the large values into smaller pieces. These pieces are then stored in a separate TOAST table, while the original tuple retains a pointer to this external stored data. </p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.31.16-AM-1.png" class="kg-image" alt="When we insert a tuple that's too large for a single page, a new page is created. The tuple could be fragmented between two pages" loading="lazy" width="1858" height="726" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/10/Screenshot-2023-10-06-at-11.31.16-AM-1.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/10/Screenshot-2023-10-06-at-11.31.16-AM-1.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/10/Screenshot-2023-10-06-at-11.31.16-AM-1.png 1600w, https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.31.16-AM-1.png 1858w" sizes="(min-width: 720px) 720px"><figcaption><i><em class="italic" style="white-space: pre-wrap;">When we insert a tuple that's too large for a single page, a new page is created.</em></i></figcaption></figure><h3 id="what-is-a-dead-tuple">What is a dead tuple? </h3><p>A key aspect to understand (and this will influence our PostgreSQL database size, as we’ll see shortly) is that when you delete data in PostgreSQL via <code>DELETE FROM</code>,  you’re not actually deleting it but marking the rows as unavailable. These unavailable rows are usually referred to as “dead tuples.”</p><p>When you run <code>UPDATE</code>, the row you’re updating will also be marked as a dead tuple. Then, PostgreSQL will insert a new tuple with the updated column. </p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.36.00-AM.png" class="kg-image" alt="A page in a Postgres table with tuples that have been deleted or updated. The old instances are now dead tuples" loading="lazy" width="1858" height="842" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/10/Screenshot-2023-10-06-at-11.36.00-AM.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/10/Screenshot-2023-10-06-at-11.36.00-AM.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/10/Screenshot-2023-10-06-at-11.36.00-AM.png 1600w, https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.36.00-AM.png 1858w" sizes="(min-width: 720px) 720px"><figcaption><i><em class="italic" style="white-space: pre-wrap;">A page in a Postgres table with tuples that have been deleted or updated. The old instances are now dead tuples</em></i></figcaption></figure><p>You might be wondering <a href="https://www.tigerdata.com/blog/postgres-for-everything" rel="noreferrer">why PostgreSQL</a> does this. Dead tuples are actually a compromise to reduce excessive locks on tables during concurrent operations, multiple connections, and simplifying transactions. Imagine a transaction failing halfway through its execution; it is much easier to revert a change when the old data is still available than trying to rewind each action in an idempotent way. Furthermore, this mechanism supports the easy and efficient implementation of rollbacks, ensuring data consistency and integrity during transactions. </p><p>The trade-off, however, is the increased database size due to the accumulation of dead tuples, necessitating regular maintenance to reclaim space and maintain performance… What brings us to table bloat.</p><h3 id="what-is-table-bloat">What is table bloat?</h3><p>When a tuple is deleted or updated, its old instance is considered a dead tuple. The issue with dead tuples is that they’re effectively still a tuple on disk, taking up storage space—yes, that storage page that is costing you money every month. </p><p>Table bloat refers to this excess space that dead tuples occupy in your PostgreSQL database, which not only leads to an inflated table size but also to increased I/O and slower queries. Since PostgreSQL runs under the MVCC system, it doesn't immediately purge these dead tuples from the disk. Instead, they linger until a vacuum process reclaims their space.</p><p>Table bloat also occurs when a table contains unused pages, which can accumulate as a result of operations such as mass deletes.</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://timescale.ghost.io/blog/content/images/2023/10/table-bloat.png" class="kg-image" alt="A visualization of table bloat in PostgreSQL. Pages contain many dead tuples and a lot of empty space" loading="lazy" width="1698" height="544" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/10/table-bloat.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/10/table-bloat.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/10/table-bloat.png 1600w, https://timescale.ghost.io/blog/content/images/2023/10/table-bloat.png 1698w" sizes="(min-width: 720px) 720px"><figcaption><i><em class="italic" style="white-space: pre-wrap;">A visualization of table bloat in PostgreSQL. Pages contain many dead tuples and a lot of empty space</em></i></figcaption></figure><h3 id="what-is-vacuum">What is <code>VACUUM</code>?</h3><p>Dead tuples get cleaned and deleted from storage when the <code>VACUUM</code> command runs: </p><pre><code class="language-SQL">VACUUM customers;</code></pre><p><a href="https://www.postgresql.org/docs/current/sql-vacuum.html">Vacuum</a> has a lot of roles, but the relevant point for this article is that vacuum removes dead tuples once all connections using the dead tuples are closed. <code>VACUUM</code> by itself will not delete pages, though. Any pages created by a table will stay allocated, although the memory in those pages is now usable space after running vacuum.</p><h3 id="what-is-autovacuum">What is autovacuum? </h3><p>Postgres conveniently includes a daemon to automatically run vacuum on tables that get heavy insert, update, and delete traffic. It operates in the background, monitoring the database to identify tables with accumulating dead tuples and then initiating the vacuum process autonomously. </p><p>Autovacuum comes enabled by default, although the threshold PostgreSQL uses to enable autovacuum is very conservative. </p><h3 id="what-is-vacuum-full">What is VACUUM FULL?</h3><p>Autovacuum helps with dead tuples, but what about unused pages? </p><p>The <code>VACUUM FULL</code> command is a more aggressive version of <code>VACUUM</code> that locks the table, removes dead tuples and empty pages, and then returns the reclaimed space to the operating system. <code>VACUUM FULL</code> can be resource-intensive and requires an exclusive lock on the table during the process. We’ll come back to this later.</p><p>Now that you have the necessary context, let’s jump into the advice.</p><h2 id="how-to-reduce-your-postgresql-database-size">How To Reduce Your PostgreSQL Database Size</h2><h3 id="use-timescale-compression">Use Timescale compression </h3><p>There are different ways we can compress our data to consistently save storage space. <a href="https://www.postgresql.org/docs/current/storage-toast.htm">PostgreSQL has some compression mechanisms</a>, but if you want to take data compression even further, especially for time-series data, you should use <a href="https://www.tigerdata.com/blog/building-columnar-compression-in-a-row-oriented-database" rel="noreferrer">Timescale’s columnar compression</a>.</p><p>It allows you to dramatically compress data through a provided <code>add_compression_policy()</code> function. To achieve high compression rates, <a href="https://timescale.ghost.io/blog/time-series-compression-algorithms-explained/">Timescale uses various compression techniques</a> depending on data types to reduce your data footprint. Timescale also uses column stores to merge many rows into a single row, saving space.</p><p>Let's illustrate how this works with an example.</p><p>Let’s say we have a <a href="https://www.tigerdata.com/blog/database-indexes-in-postgresql-and-timescale-cloud-your-questions-answered" rel="noreferrer">hypertable</a> with a week's worth of data. Imagine that our application generally only needs data from the last day, but we must keep historical data around for reporting purposes. We could run <code>SELECT add_compression_policy('my_table', INTERVAL '24 hours');</code> which automatically compresses rows in the <code>my_table</code> hypertable older than 24 hours. </p><p>Timescale’s compression would combine all the rows into a single row, where each column contains an array of all the row's data in segments of 1,000 rows. Visually, this would take a table that looks like this:</p><pre><code class="language-SQL">| time                   | location | temperature |
|------------------------|----------|-------------|
| 2023-09-20 00:16:00.00 | garage   | 80          |
| 2023-09-21 00:10:00.00 | attic    | 92.3        |
| 2023-09-22 00:5:00.00  | basement | 73.9        |
</code></pre><p>And compress it down to a table like this:</p><pre><code class="language-SQL">| time                                                                     | location                    | temperature               |
|--------------------------------------------------------------------------|-----------------------------|---------------------------|
| [2023-09-20 00:16:00.00, 2023-09-21 00:10:00.00, 2023-09-22 00:5:00.00]  | [garage, attic, basement]   | [80, 92.3, 73.9]          |
</code></pre><p>To see exactly how much space we can save, let's run compression on a table with 400 rows, 50 rows per day for the last seven days, that looks like this:</p><pre><code class="language-SQL">CREATE TABLE conditions (
  time        TIMESTAMPTZ       NOT NULL,
  location    TEXT              NOT NULL,
  temperature DOUBLE PRECISION  NULL,
);

SELECT create_hypertable('conditions', 'time');
</code></pre><p>Next, we'd add a compression policy to run compression on <code>conditions</code> for rows older than one day:</p><pre><code class="language-SQL">SELECT add_compression_policy('conditions', INTERVAL '1 day')</code></pre><p>In the Timescale platform, if we navigate to the <a href="https://docs.timescale.com/use-timescale/latest/services/service-explorer/">Explorer tab</a> under Services, we’d see our table shrink from 72 kB to 16&nbsp;kB—78% savings!</p><figure class="kg-card kg-image-card kg-card-hascaption"><img src="https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.50.48-AM.png" class="kg-image" alt="The Timescale console showing a 78% space reduction in table size due to compression" loading="lazy" width="1858" height="390" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2023/10/Screenshot-2023-10-06-at-11.50.48-AM.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2023/10/Screenshot-2023-10-06-at-11.50.48-AM.png 1000w, https://timescale.ghost.io/blog/content/images/size/w1600/2023/10/Screenshot-2023-10-06-at-11.50.48-AM.png 1600w, https://timescale.ghost.io/blog/content/images/2023/10/Screenshot-2023-10-06-at-11.50.48-AM.png 1858w" sizes="(min-width: 720px) 720px"><figcaption><i><em class="italic" style="white-space: pre-wrap;">The Timescale console showing a 78% space reduction in table size due to compression</em></i></figcaption></figure><p>This is a simple example, but it shows the potential that Timescale compression has to reduce storage space. </p><h3 id="monitor-dead-tuples">Monitor dead tuples</h3><p>A great practice to ensure you’re using as little storage as possible is to consistently monitor the number of dead tuples in each table.This is the first step towards putting together an efficient PostgreSQL storage management strategy.</p><p>To see pages and tuples in action, you can use <a href="https://www.postgresql.org/docs/current/pgstattuple.html"><code>pgstattuple()</code></a>, an extension provided by the Postgres maintainers to gain insights into how our tables manage tuples:</p><pre><code class="language-sql">CREATE EXTENSION IF NOT EXISTS pgstattuple;</code></pre><p>If you run the following query, </p><pre><code class="language-sql">SELECT * FROM pgstattuple('my_table');</code></pre><p>Postgres would give you a table of helpful information in response:</p><pre><code class="language-sql"> table_len | tuple_count | tuple_len | tuple_percent | dead_tuple_count | dead_tuple_len | dead_tuple_percent | free_space | free_percent 
-----------+-------------+-----------+---------------+-----------------+----------------+--------------------+------------+--------------
  81920000 |      500000 |  40000000 |          48.8 |           10000 |        1000000 |                1.2 |     300000 |          0.4</code></pre><ul><li><code>table_len</code> tells you how big your table is in bytes, including data, indexes, toast tables, and free space.</li><li><code>dead_tuple_len</code> tells how much space is being occupied by dead tuples which can be reclaimed by vacuuming.</li><li><code>free_space</code> indicates the unused space within the allocated pages of the table.. Take note that <code>free_space</code> will reset for every new page created.</li></ul><p>You can also perform calculations or transformations on the result to make the information more understandable. For example, this query calculates the ratios of dead tuples and free space to the total table length, giving you a clearer perspective on the storage efficiency of your table:</p><pre><code class="language-sql">SELECT
(dead_tuple_len * 100.0 / table_len) as dead_tuple_ratio,
(free_space * 100.0 / table_len) as free_space_ratio
FROM
pgstattuple('my_table');</code></pre><h3 id="run-autovacuum-more-frequently">Run autovacuum more frequently</h3><p>If your table is experiencing table bloat, having autovacuum run more frequently may help you free up wasted storage space.</p><p>The default thresholds and values for autovacuum are in <code>postgresql.conf</code>. Updating <code>postgresql.conf</code> will change the autovacuum behavior for the whole Postgres instance. However, this practice is generally not recommended, since some tables will have a higher affinity for dead tuples than others.</p><p>Instead, you should update autovacuum's settings per table. For example, consider the following query:</p><pre><code class="language-SQL">ALTER TABLE my_table SET (autovacuum_vacuum_scale_factor = 0, autovacuum_vacuum_threshold = 200)</code></pre><p>This will update <code>my_table</code> to have autovacuum run after 200 tuples have been updated or deleted. </p><p>More information about additional autovacuum settings are in the<a href="https://www.postgresql.org/docs/current/runtime-config-autovacuum.html"> PostgreSQL documentation</a>. Each database and table will require different settings for how often autovacuum should run, but running vacuum often is a great way to reduce storage space.</p><p>Also, keep an eye on long-running transactions that might block autovacuum, leading to issues. You can use PostgreSQL’s <code>pg_stat_activity</code> view to identify such transactions, canceling them if necessary to allow autovacuum to complete its operations efficiently:</p><pre><code class="language-sql">SELECT pid, NOW() - xact_start AS duration, query, state
FROM pg_stat_activity
WHERE (NOW() - xact_start) &gt; INTERVAL '5 minutes';

#Cancelling
SELECT pg_cancel_backend(pid);</code></pre><p>You could also inspect long-running vacuum processes and adjust the <code>autovacuum_work_mem</code> parameter to increase the memory allocation for each autovacuum invocation, <a href="https://www.timescale.com/learn/postgresql-performance-tuning-key-parameters">as we discussed in our article about PostgreSQL fine tuning</a>.</p><h3 id="reclaim-unused-pages">Reclaim unused pages</h3><p>Autovacuum and vacuum will free up dead tuples, but you’ll need the big guns to clean up unused pages. </p><p>As we saw previously, running <code>VACUUM FULL my_table</code> will reclaim pages, but it has a significant problem: it exclusively locks the entire table. A table running <code>VACUUM FULL</code>  cannot be read or written to while the vacuum has the lock, which can take a long time to finish. This is usually an instant no-go for any production database.</p><p>The PostgreSQL community has a solution, <a href="https://github.com/reorg/pg_repack">pg_repack</a>. <code>pg_repack</code> is an extension that will clean up unused pages and bloat from a table by cloning a given table, swapping the original table with the new table, and then deleting the old table. All these operations are done with minimal exclusive locks, leading to less downtime. </p><p>At the end of the <code>pg_repack</code> process, the pages associated with the original table become deleted from storage, and the new table only has the absolute minimum number of pages to store its rows, thus freeing table bloat.</p><h3 id="find-unused-indexes">Find unused indexes </h3><p>As we mention <a href="https://www.timescale.com/learn/postgresql-performance-tuning-optimizing-database-indexes">in this article on idexing design</a>, over-indexing is a frequent issue in many large PostgreSQL databases. Indexes consume disk space, so removing unused or underutilized indexes will help you keep your PostgreSQL database lean. </p><p>You can use <code>pg_stat_user_indexes</code> to spot opportunities: </p><pre><code class="language-sql">SELECT
relname AS table_name,
indexrelname AS index_name,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size,
idx_scan AS index_scan_count
FROM
pg_stat_user_indexes
WHERE
idx_scan &lt; 50 -- Choose a threshold that makes sense for your application.
ORDER BY
index_scan_count ASC,
pg_relation_size(indexrelid) DESC;</code></pre><p>(This query looks for indexes with fewer than 50 scans, but this is an arbitrary number. You should adjust it based on your own usage patterns.)</p><h3 id="arrange-columns-by-data-type-from-largest-to-smallest">Arrange columns by data type (from largest to smallest)</h3><p>In PostgreSQL, storage efficiency is significantly influenced by the ordering of columns, which is closely related to alignment padding determined by the size of the column types. Each data type is aligned at memory addresses that are multiples of their size. </p><p>This alignment is systematic, ensuring that data retrieval is efficient and that the architecture adheres to specific memory and storage management protocols. But this can also lead to unused spaces, as the alignment necessitates padding to meet the address multiple criteria.</p><p>The way to fix this is to strategically order you columns from the largest to the smallest data type in your table definitions. This practical tip will help you minimize wasted space. <a href="https://www.timescale.com/learn/postgresql-performance-tuning-designing-and-implementing-database-schema">Check out this article for a more in-depth explanation</a>.</p><h3 id="delete-old-data-regularly">Delete old data regularly </h3><p>You should always ask yourself: how long should I keep data around? Setting up data retention policies is essential for managing storage appropriately. Your users may not need data older than a year ago. Deleting old, unused records and indexes regularly is an easy win to reduce your database size.  </p><p>Timescale can automatically delete old data for us using <a href="https://docs.timescale.com/use-timescale/latest/data-retention/about-data-retention/">retention policies</a>. Timescale’s hypertables are <a href="https://timescale.ghost.io/blog/when-to-consider-postgres-partitioning/">automatically partitioned by time</a>, which helps a lot with data retention.  Retention policies automatically delete partitions (which are called chunks in Timescale) once the data contained in such partition is older than a given interval. </p><p>You can <a href="https://docs.timescale.com/use-timescale/latest/data-retention/create-a-retention-policy/">create a retention policy</a> by running:</p><pre><code class="language-SQL">SELECT add_retention_policy('my_table', INTERVAL '24 hours');</code></pre><p><br>In this snippet, Timescale would delete chunks older than 24 hours from <code>my_table</code>.</p><h2 id="wrap-up">Wrap-Up</h2><p>We examined how table bloat and dead tuples can contribute to wasted storage space, which not only affects your pocket but also the performance of your large PostgreSQL tables. </p><p>To make sure you’re reducing your PostgreSQL database size down to its minimum, make sure to enable Timescale compression, to use data retention policies, and to set up a maintenance routine to periodically and effectively delete your dead tuples and reclaim your unused pages.  </p><p>All these techniques together provide a holistic approach to maintaining a healthy PostgreSQL database and keeping your PostgreSQL database costs low.</p>]]></content:encoded>
        </item>
    </channel>
</rss>