<?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 14:08:48 GMT</lastBuildDate>
        <atom:link href="https://www.tigerdata.com/blog" rel="self" type="application/rss+xml"/>
        <ttl>60</ttl>
        <item>
            <title><![CDATA[How to Measure Your IIoT PostgreSQL Table]]></title>
            <description><![CDATA[Learn how to measure your IIoT PostgreSQL table's size, ingest capacity, and query speed with practical SQL queries as your data grows over time.]]></description>
            <link>https://www.tigerdata.com/blog/measure-your-iiot-postgresql-table</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/measure-your-iiot-postgresql-table</guid>
            <category><![CDATA[IoT]]></category>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Doug Pagnutti]]></dc:creator>
            <pubDate>Thu, 12 Mar 2026 18:50:42 GMT</pubDate>
            <media:content medium="image" href="https://timescale.ghost.io/blog/content/images/2026/03/How_to_measure_banner.png">
            </media:content>
            <content:encoded><![CDATA[<p>I was doing some validation tests for an essay about <a href="https://www.tigerdata.com/blog/the-iiot-postgresql-performance-envelope"><u>the performance envelope for an IIoT PostgreSQL database</u></a> and realized that measuring a database table is not as straightforward as I assumed it would be.&nbsp;</p><p>The general idea was that I would insert IIoT data into a table and then measure the size and performance of the table as it grows. But how do you actually read the size of a table? What is performance? How can we quantify these values in a way that’s useful for us engineers?</p><p>Here’s what I did.</p><h2 id="table-size">Table Size</h2><p>There are two key measurements that define a table’s size: How many rows does it have and how much disk space does it occupy.&nbsp;</p><h3 id="row-count">Row Count</h3><p>For small tables, this is straightforward:</p><pre><code class="language-SQL">SELECT COUNT(*) FROM &lt;table_name&gt;</code></pre><p>However, that query requires scanning every row in the table. For typical IIoT tables, like the ones I was testing, that might be billions of rows and might take minutes to execute.</p><p>Instead there’s a much faster query:</p><pre><code class="language-SQL">SELECT reltuples::bigint AS row_count&nbsp;
FROM pg_class&nbsp;
WHERE relname = ‘&lt;table_name&gt;’</code></pre><p>This is the row count that PostgreSQL uses for the query planner. It’s not guaranteed to match the row count exactly, because it’s not continuously updated, but it’s close enough and returns almost instantly.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">⚠️</div><div class="kg-callout-text">For hypertables created with TimescaleDB, reltuples should not be used. Instead use <a href="https://www.tigerdata.com/docs/api/latest/hyperfunctions/approximate_row_count"><u>approximate_row_count()</u></a></div></div><h3 id="size-on-disk">Size on Disk</h3><p>PostgreSQL stores table data across several components: the heap (the main table data), the indices, and TOAST storage (where large values get stashed). All three contribute to the table size and overall storage requirements as shown in the following image.&nbsp;</p><figure class="kg-card kg-image-card"><img src="https://timescale.ghost.io/blog/content/images/2026/03/data-src-image-1d878c42-0846-4e35-aac5-83bfe8f9dfca.png" class="kg-image" alt="" loading="lazy" width="1600" height="510" srcset="https://timescale.ghost.io/blog/content/images/size/w600/2026/03/data-src-image-1d878c42-0846-4e35-aac5-83bfe8f9dfca.png 600w, https://timescale.ghost.io/blog/content/images/size/w1000/2026/03/data-src-image-1d878c42-0846-4e35-aac5-83bfe8f9dfca.png 1000w, https://timescale.ghost.io/blog/content/images/2026/03/data-src-image-1d878c42-0846-4e35-aac5-83bfe8f9dfca.png 1600w" sizes="(min-width: 720px) 720px"></figure><p>Here’s the query I used to get the three separate components.</p><pre><code class="language-SQL">SELECT pg_relation_size(‘&lt;table_name&gt;') AS heap_size,
pg_indexes_size('&lt;table_name&gt;') AS indexes_size,
pg_table_size('&lt;table_name&gt;')
  - pg_relation_size('&lt;table_name&gt;') AS toast_size;</code></pre><p>This will return the sizes in bytes, but you can also use the function <code>pg_size_pretty()</code> to get a more human readable output.</p><h2 id="ingest-capacity">Ingest Capacity</h2><p>Ingest capacity is critical to IIoT workflows, and it’s where a lot of systems run into serious trouble. How do you measure capacity? You can either get an approximate value from current ingest speeds, or push your database to the limit.</p><h3 id="if-you-already-have-a-data-source-connected">If You Already Have a Data Source Connected</h3><p>If your data stream is already connected, you can look at how long ingests are taking and figure out the capacity from that.</p><p>This requires the built-in tool <code>pg_stat_statements</code> which is essential for any serious database. To enable it (it ships with PostgreSQL, so it’s always available) run the following query:</p><pre><code class="language-SQL">CREATE EXTENSION IF NOT EXISTS pg_stat_statements;</code></pre><p>Once it’s running, it creates a table called <code>pg_stat_statements</code> that you can query for your INSERT performance:</p><pre><code class="language-SQL">SELECT 
    query,
    calls,
&nbsp;&nbsp;&nbsp;&nbsp;rows,
&nbsp;&nbsp;&nbsp;&nbsp;total_exec_time / 1000 AS total_time_sec,
&nbsp;&nbsp;&nbsp;&nbsp;mean_exec_time AS avg_ms_per_call,
&nbsp;&nbsp;&nbsp;&nbsp;rows / NULLIF(calls, 0) AS avg_rows_per_call,
&nbsp;&nbsp;&nbsp;&nbsp;rows / NULLIF(total_exec_time / 1000, 0) AS rows_per_sec
FROM pg_stat_statements
WHERE query ILIKE '%INSERT%&lt;table_name&gt;%'
ORDER BY total_exec_time DESC;</code></pre><p>This gives you a picture of real ingest performance based on what your application is actually doing. You'll see how many rows each call inserts (obviously <a href="https://www.tigerdata.com/blog/mqtt-sql-practical-guide-sensor-data-ingestion"><u>you’re batching</u></a>), the average time per call, and a rough rows-per-second figure. The time it takes to insert a batch divided by the period of your desired insertions gives you a rough estimate of how much ingest capacity you’re using.</p><p>You can reset the stats whenever you want for a fresh baseline:</p><pre><code class="language-SQL">SELECT pg_stat_statements_reset();</code></pre><p>By measuring this as your table grows, you’ll get a good sense of how your ingest capacity is evolving and you’ll be able to deal with it well before it becomes an issue.</p><h3 id="the-actual-ingest-capacity">The actual ingest capacity</h3><p>If you don’t mind really pushing your table to its limits (and maybe breaking it), you can try to ingest as much as possible and see if the database keeps up. I wrote a full walkthrough for this, including the SQL for generating realistic IIoT data and a scripted test loop, in <a href="https://www.tigerdata.com/blog/how-to-break-postgresql-iiot-database-learn-something-in-process"><u>How to Break Your PostgreSQL IIoT Database and Learn Something in the Process</u></a>.&nbsp;</p><h2 id="query-speed">Query Speed</h2><p>Query speed is the most obvious metric for a database, as it affects everyone using the data. However, I found it to be one of the most difficult to generalize. Every application will have specific queries that are important, and different definitions of what is ‘fast enough’.&nbsp; It’s also something that tends to degrade over time and only become an issue well into the life of the table.</p><h3 id="for-queries-you%E2%80%99re-already-running">For queries you’re already running</h3><p>If you already have dashboards running, or your analysis workflow in place, you can again use <code>pg_stat_statements</code>. Here's how to pull information for the 20 slowest queries:</p><pre><code class="language-SQL">SELECT
    query,
    calls,
    rows,
    mean_exec_time AS avg_ms,
    total_exec_time / 1000 AS total_time_sec,
    stddev_exec_time AS stddev_ms,
    rows / NULLIF(calls, 0) AS avg_rows_returned
FROM pg_stat_statements
WHERE query ILIKE '%SELECT%&lt;table_name&gt;%'
ORDER BY total_exec_time DESC
LIMIT 20;</code></pre><h3 id="for-more-general-queries">For more general queries</h3><p>IIoT queries tend to fall into two categories: wide (what is the state of all devices at a specific time?) and deep (what is the history of a particular device?). By running at least one example from each type, you’ll get a sense of how quickly these types of queries will return.</p><p>Generic Wide Query</p><pre><code class="language-SQL">SELECT DISTINCT ON (tag_id) 
  tag_id, 
  time, 
  value
FROM &lt;table_name&gt;
ORDER BY tag_id, time DESC
LIMIT 100
</code></pre><p>This returns the most recent value from 100 tags.</p><p>Generic Deep Query</p><pre><code class="language-SQL">SELECT 
    tag_id,
    DATE_TRUNC('hour',time) as hour,
    AVG(value) as hourly_average
FROM &lt;table_name&gt;
WHERE tag_id = &lt;specific tag_id&gt;
GROUP BY DATE_TRUNC('hour',time)
ORDER BY hour DESC
LIMIT 100</code></pre><p>This returns the past 100 hourly averages from one specific tag.</p><div class="kg-card kg-callout-card kg-callout-card-blue"><div class="kg-callout-emoji">💡</div><div class="kg-callout-text">It's important to run these queries multiple times to get a robust measurement. There are a lot of internal optimizations that PostgreSQL uses to speed up common queries and it’s therefore likely to run faster after a few executions.</div></div><h2 id="putting-it-all-together">Putting It All Together</h2><p>The real value comes from combining these measurements as the table grows. Here's the general approach I followed for my essay:</p><ol><li>Create a simple IIoT table schema and common index.</li><li>Measure table size (rows + disk space), query times, and ingest time for a couple standard batches.</li><li>Insert many batches as fast as possible so the table grows quickly.</li><li>Repeat steps 2 and 3 until some predefined limit (usually disk space or query time)</li></ol><p>If I was instead using a real production system, I would rely more on <code>pg_stat_statements</code> to track query and ingest rates. Doing this every day when the system is new and then a weekly check will ensure you know exactly how your table is evolving.</p>]]></content:encoded>
        </item>
        <item>
            <title><![CDATA[How to Break Your PostgreSQL IIoT Database and Learn Something in the Process]]></title>
            <description><![CDATA[Stress test your PostgreSQL IIoT database to identify bottlenecks, optimize performance, and prevent failure. Learn how to break it safely and design with margin.]]></description>
            <link>https://www.tigerdata.com/blog/how-to-break-postgresql-iiot-database-learn-something-in-process</link>
            <guid isPermaLink="true">https://www.tigerdata.com/blog/how-to-break-postgresql-iiot-database-learn-something-in-process</guid>
            <category><![CDATA[IoT]]></category>
            <category><![CDATA[PostgreSQL]]></category>
            <dc:creator><![CDATA[Doug Pagnutti]]></dc:creator>
            <pubDate>Wed, 18 Feb 2026 19:43:56 GMT</pubDate>
            <media:content medium="image" href="https://timescale.ghost.io/blog/content/images/2026/02/thumbnail--4-.png">
            </media:content>
            <content:encoded><![CDATA[<p>As engineers, we're taught to design for reliability. We do design calculations, run simulations, build and test prototypes, and even then we recognize that these are imperfect, so we include safety factors. When it comes to the Industrial Internet of Things (IIoT) though, we rarely give the same level of scrutiny to the components that we rely on.</p><p>What if we treated our IIoT database the same way we treated the physical things we produce? We build and design a prototype database, and then <a href="https://timescale.ghost.io/blog/postgres-optimization-treadmill/" rel="noreferrer">put it through some serious testing</a>, even to failure.</p><h2 id="the-value-and-perils-of-stress-testing">The Value (and Perils) of Stress Testing</h2><p>Think of database stress testing as a destructive materials test for your data storage. You wouldn't trust a bridge made of untested steel, so don’t trust your database until you know its limits.</p><p><strong>The Value:</strong></p><ul><li><strong>Identify Bottlenecks:</strong> Stress testing reveals the weak links—what is likely to fail first? Will you run out of storage? Will your queries get bogged down? Or will you hit the dreaded ingest wall (when data comes in faster than it can be stored)?</li><li><strong>Determine Real-World Behaviour:</strong> You'll find out exactly how your database performance changes as the amount of data increases. What issues are future-you going to struggle with?</li><li><a href="https://timescale.ghost.io/blog/postgres-optimization-treadmill/" rel="noreferrer"><strong>Optimize Configuration</strong></a><strong>:</strong> Just like you might build a few different prototypes and see how it affects failure modes, changing your database configuration, especially when it comes to indices, can dramatically affect how it behaves. Building a rigorous stress testing framework provides a safe way to optimize your design.</li></ul><p>I hope it goes without saying, but please, please don’t run this on your production environment. Even if it’s technically a different database but the same hardware, this test can wreak havoc on your resources and crash your system. You’ve been warned.</p><h2 id="what-to-measure">What to Measure?</h2><p>There’s no point going through all the effort to break your system if you don’t learn anything. Assuming you’re using a PostgreSQL database (<a href="https://www.tigerdata.com/blog/its-2026-just-use-postgres"><u>It’s 2026, Just Use PostgreSQL</u></a>), here is a decent set of metrics to keep track of while you’re putting your database through its paces.</p><h3 id="table-size">Table Size</h3><p>The size of a Postgresql table is generally measured by number of rows, but the actual space on disk that it occupies is a sum of the heap (the main relational table), the indices, and the TOAST (storage for large objects).</p><p>The following query will give the number or rows as well as the size of each component of the table in bytes.</p><pre><code class="language-SQL">SELECT
      reltuples::bigint AS row_count,
      pg_relation_size('iiot_history') AS heap_size,
      pg_indexes_size('iiot_history') AS indices_size,
      pg_table_size('iiot_history') -
            pg_relation_size('iiot_history') AS toast_size
FROM pg_class WHERE relname = 'iiot_history';</code></pre><p>The reason for the odd row_count is that counting rows the standard way, with COUNT(*), requires scanning the whole table, which is going to be painfully slow when we’re building a table big enough to break things.</p><h3 id="table-performance">Table Performance</h3><p>The best way to measure table performance is to use the actual queries that your production system will use. At a minimum, this should include your batched INSERT (you always batch, right?) and at least one common SELECT. Keep in mind that for a table with N rows, the timing for queries tend to be either constant, log(N), N or worse depending on how the indices are structured.&nbsp;</p><p>You can get very accurate timing info from running your queries with the prefix EXPLAIN ANALYZE, and it’s worth doing this at least once to see what the database is doing under the hood. However, I recommend running the whole test with a scripting language and then just timing the execution of that particular step.&nbsp;</p><h3 id="server-performance">Server Performance</h3><p>Don’t forget the engine that’s driving all this machinery. You’ll need to watch the CPU, Memory, Storage, and Network Bandwidth. People in the IT world tend to talk about headroom for a server, and that’s what you’re really looking at: how much spare capacity do you have? Your CPU and Memory usage might spike at times, but the important thing is that it’s not always running at max capacity.</p><p>There are a lot of free and paid tools to monitor these variables. I almost always do this type of test in a VM (easier to clean up the mess when it all breaks) and I like to use <a href="https://prometheus.io/"><u>Prometheus</u></a> but honestly Perfmon in Windows or Top in Linux gives you all you really need.</p><h3 id="setting-limits">Setting Limits</h3><p>It’s helpful to set some limits on these parameters so you know when to stop the test. For database size, it might be some measurement like a year's worth of data, or when the drive is 80% full. For ingest timing, I suggest stopping when inserting takes longer than the desired ingest frequency—this is the ingest bottleneck and something you really want to avoid in production. Scan times can be limited by the time it takes for a specific query. Maybe calculating the average value from one tag over the past hour must be less than 10s.</p><h2 id="how-to-simulate-data">How to Simulate Data?</h2><p>There are lots of ways to insert data, but it’s usually a tradeoff between how well the data represents real scenarios and how long it takes to run the test.</p><p>The following is one of my favourite methods for injecting large amounts of data into an IIoT database:</p><p>Say you have a classic IIoT history table like the following:</p><pre><code class="language-SQL">CREATE TABLE iiot_history(
	time TIMESTAMPZ NOT NULL,
	tag_id INT NOT NULL,
	value DOUBLE PRECISION,
	PRIMARY KEY (tag_id, time)
);</code></pre><p>If you expect to ingest 10,000 tags at 1s intervals, you can use the following INSERT query to add a day’s worth of history to the back end of your table.</p><pre><code class="language-SQL">INSERT INTO iiot_history(time, tag_id, value)
	SELECT *, random() as value 
FROM(
		SELECT generate_series(
			min_date-INTERVAL '1day',
			min_date-INTERVAL '1s',
			INTERVAL '1s') as time
		FROM (SELECT LEAST(NOW(),MIN(time)) AS min_date 
FROM iiot_history)
),
		generate_series(1,10000) as tag_id;</code></pre><p>This will generate random data values for every second during a day and for every tag_id from 1 to 10,000. Not exactly as interesting as real data, but enough to fill up your table.</p><p>The nice thing about this query is that you should be able to run it in parallel to your real-time data pipeline and it won’t mess with your data (aside from potentially locking your table while it runs). It’s also easy to modify this query to inject more or less tags as well as change the time interval if you’re playing around with different configurations.</p><p>If you use this query, or whichever one you prefer, in a script (I usually use Python), then you can automate the whole test. Something along the lines of:</p><ol><li>Get database size</li><li>Run select queries, measure execution time</li><li>Run insert queries several times, measure and average execution time</li><li>Artificially grow database size</li><li>Repeat 1-3 until one of the failure conditions is reached.</li></ol><h2 id="how-to-interpret-results-and-what-to-expect-in-the-real-world">How to Interpret Results and What to Expect in the Real World?</h2><p>Your test results will give you some clear data points, but you still need to do some interpreting.</p><ul><li><strong>Identify the Limiting Component:</strong> Where did the database fail? If it’s a query that took too long, you might be able to speed things up with a clever index. If it’s an insert that took too long, you might be able to speed things up by removing that clever index you added earlier.</li><li><strong>Optimize:</strong> There’s a lot you can do to improve table performance before throwing the whole thing out in frustration:<ol><li><strong>Proper Indexing:</strong> Choosing an index is almost always a tradeoff, for example: Indexing the tag_id column before the time column will speed up most queries, at the cost of slower inserts as the table grows. Indexing the time column first will avoid the ‘ingest wall’ at the cost of slower queries. Figure out which solution is best.</li><li><strong>Plan for the future:</strong> Will you need more hardware in a few months or a few years? Being able to estimate the life of your existing architecture means you won’t be caught unawares when it no longer suffices.</li><li><strong>Partitioning/Chunking:</strong> For very large tables, you may need to partition appropriately (see PostgreSQL extensions like <a href="https://www.tigerdata.com/timescaledb"><u>TimescaleDB</u></a>). How great would it be to learn you’ll need this before you actually need this.</li></ol></li><li><strong>Add a Safety Factor:</strong> If your test showed a maximum reliable throughput of 15,000 rows/sec, set your operational limit to 10,000 rows/sec. The real world has peaks, unexpected queries, and background maintenance tasks that will steal resources. Like we do with all engineering products, design with margin.</li></ul><p>If you treat your database like a prototype and really put it through its paces, you’ll get a preview of how it’ll behave in the future and make good, proactive design decisions instead of struggling in the future. Now, go break something (and learn).</p>]]></content:encoded>
        </item>
    </channel>
</rss>