- Do not size one-second telemetry from data type sizes alone. Load a sample into the exact production schema and measure it with
pg_relation_size,pg_indexes_sizeandpg_total_relation_size. - Every figure on this page measured in bytes, pages or WAL is an illustrative measurement on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly): it shows the method, it is not a production benchmark and it is not representative of your server. Estimates built on it inherit that limit.
- Illustrative measurement on the small PGlite sample (not a production benchmark): a narrow row (point_id, ts, value, quality) took 60.3 bytes of table plus 33.1 bytes of primary-key index, 93.6 bytes per reading, before WAL, replicas and backups. This is a figure for that schema, sample and build, not a universal per-row size.
- Also illustrative measurements on the same PGlite sample: reordering the columns cut the table to 52.2 bytes per row, and a wide row holding four values took 29.5 bytes per value including its index.
- Estimate from stated assumptions, built on the illustrative PGlite measurement (so it demonstrates the arithmetic, not a capacity figure for a real server): 2,000 points at one reading per second give 172.8 million rows a day; at 93.6 bytes per reading that is an estimated 16.17 GB a day and about 485 GB for an assumed 30-day raw window.
- Write every assumption next to the estimate and replace the PGlite figures with a measurement on the target server: the sample here ran on a 32-bit WebAssembly build of PostgreSQL, WAL volume depends on server settings, and compressed or columnar engines need their own measurement.
What this page sizes, and for whom
About the numbers on this page. The byte counts, index sizes, rows per page, WAL bytes and bytes per row below are illustrative measurements on a small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly, running in a single session). They show how the method works and what the output looks like. They are not a production benchmark and are not representative of your server: alignment, index growth, WAL and checkpoint behaviour can all differ on a 64-bit production build with your settings and data. Every capacity estimate here is built on those illustrative figures, so treat it as a demonstration of the arithmetic and replace the inputs with measurements from your own server.
This is for the engineer who has to answer "how much disk do we need?" before a project starts: for example a BMS integrator pulling chiller power, AHU supply-air temperature and zone CO2 at one-second resolution for fault detection, or an energy team logging sub-meter demand every second. At this rate storage is often an early constraint, and estimates from data type sizes alone can miss alignment, index and WAL overheads by enough to matter.
The IoT sensor data storage guide already gives an arithmetic estimate from data type sizes for one-minute data. This page replaces arithmetic with measurement: it shows how to measure bytes per reading on a sample, what the measurement includes and leaves out, and how to turn it into a capacity figure with assumptions written down. It uses plain PostgreSQL row storage because that is where the method is easiest to run; the same method applies to any engine that reports its own table sizes.
Decisions this page helps you make: raw retention window, whether one-second data needs columnar compression, table shape (narrow or wide), which indexes you can afford, and how much to provision for WAL, replicas and backups.
The method in six steps
- Copy the production schema exactly: same column order, types, primary key, secondary indexes and partitioning. Size depends on all of them.
- Load a representative sample in the order production will write it (time order across many points), with realistic values and realistic gaps, nulls and quality codes.
- Make it big enough that fixed per-table overheads (metapages, free space map, visibility map, the last partly filled page) are negligible: thousands of pages, not dozens.
- Measure table, index and total bytes, then divide by the row count.
- Extrapolate with rows per day times retention days times measured bytes per row.
- Add the items the table size does not include: WAL, replicas, backups, roll-up tables, dead rows and operational headroom, each as a written assumption.
Step 1 to 4: measure a sample
The sample below is one hour of one-second readings for 50 points (180,000 rows), inserted in time order as an ingestion service would write them. The table is the narrow design from the storage guide. VACUUM (ANALYZE) runs before measuring so that the free space map and visibility map exist, as they would on a live table.
CREATE TABLE reading (
point_id integer NOT NULL,
ts timestamptz NOT NULL,
value double precision,
quality smallint NOT NULL DEFAULT 0,
PRIMARY KEY (point_id, ts)
);
-- remember the WAL position before the load (used in step 6)
CREATE TABLE lsn_mark (label text, lsn pg_lsn);
INSERT INTO lsn_mark VALUES ('before', pg_current_wal_lsn());
INSERT INTO reading (point_id, ts, value)
SELECT p, ts, 20 + p * 0.01 + sin(extract(epoch FROM ts) / 600.0)
FROM generate_series(1, 50) AS p
CROSS JOIN generate_series('2026-10-01 00:00:00+00'::timestamptz,
'2026-10-01 00:59:59+00'::timestamptz,
interval '1 second') AS ts
ORDER BY ts, p;
INSERT INTO lsn_mark VALUES ('after', pg_current_wal_lsn());
VACUUM (ANALYZE) reading;SELECT pg_relation_size('reading') AS heap_bytes,
pg_indexes_size('reading') AS index_bytes,
pg_total_relation_size('reading') AS total_bytes,
pg_relation_size('reading', 'fsm') AS fsm_bytes,
pg_relation_size('reading', 'vm') AS vm_bytes;
SELECT round(pg_relation_size('reading')::numeric / count(*), 1) AS heap_bytes_per_row,
round(pg_indexes_size('reading')::numeric / count(*), 1) AS index_bytes_per_row,
round(pg_total_relation_size('reading')::numeric / count(*), 1) AS total_bytes_per_row
FROM reading; heap_bytes | index_bytes | total_bytes | fsm_bytes | vm_bytes
------------+-------------+-------------+-----------+----------
10846208 | 5963776 | 16842752 | 24576 | 8192
heap_bytes_per_row | index_bytes_per_row | total_bytes_per_row
--------------------+---------------------+---------------------
60.3 | 33.1 | 93.6Check the measurement against the page layout
A measurement you cannot explain may be measuring the wrong thing, so cross-check it. The PostgreSQL page layout documentation gives each row a fixed header of 23 bytes on most machines, user data starting at a MAXALIGN boundary, and a 4-byte item identifier per row. Two queries on the same sample confirm the arithmetic:
SELECT current_setting('block_size') AS block_size;
-- block_size
-- ------------
-- 8192
SELECT avg(pg_column_size(r.*))::numeric(6,1) AS avg_row_datum_bytes,
avg(pg_column_size(r.point_id)) AS point_id_b, avg(pg_column_size(r.ts)) AS ts_b,
avg(pg_column_size(r.value)) AS value_b, avg(pg_column_size(r.quality)) AS quality_b
FROM reading r;
-- avg_row_datum_bytes | point_id_b | ts_b | value_b | quality_b
-- ---------------------+------------+------+---------+-----------
-- 50.0 | 4.0000000000000000 | 8.0000000000000000 | 8.0000000000000000 | 2.0000000000000000
SELECT count(*) AS rows_on_first_page
FROM reading WHERE (ctid::text::point)[0] = 0;
-- rows_on_first_page
-- --------------------
-- 136| Part | Bytes | Note |
|---|---|---|
| Row header | 24 | 23 bytes, padded so user data starts on an 8-byte boundary |
| point_id integer | 4 | |
| Padding | 4 | timestamptz must start on an 8-byte boundary |
| ts timestamptz, value double precision | 16 | 8 + 8 |
| quality smallint | 2 | Row is 50 bytes, matching pg_column_size |
| Padding to the next row | 6 | Rows are stored at 8-byte multiples: 56 |
| Item identifier | 4 | In the page header area: 60 bytes per row |
| Rows per page | 136 | 136 x 60 = 8,160 of the 8,192 bytes; the rest is the 24-byte page header and 8 unused bytes. 8,192 / 136 = 60.2, close to the 60.3 of the illustrative PGlite measurement |
Why the sample must be large
Each table and index carries fixed overhead: metapages, the free space map and visibility map forks, and a last page that is only partly full. On a sample of a few hundred rows that overhead can dominate bytes per row. A smaller partitioned sample in the next section, with two partitions and 72,000 rows, measured 95.1 bytes per row against 93.6 here (both illustrative PGlite measurements); our reading is that most of the difference is that fixed overhead being counted twice. Measure on thousands of pages, then extrapolate.
How layout choices change bytes per reading
At 86,400 rows per point per day, a few bytes per row are worth measuring. The same 180,000 readings were loaded into four variants and measured the same way. All figures in the table are illustrative measurements on the small PGlite sample (PostgreSQL 18.6 compiled to 32-bit WebAssembly), apply to these schemas only, and are not a production benchmark; the differences between variants show what to compare on your own server, not what you will get there:
CREATE TABLE ahu_reading (
device_id integer NOT NULL,
ts timestamptz NOT NULL,
supply_temp double precision,
return_temp double precision,
zone_co2 double precision,
fan_speed double precision,
PRIMARY KEY (device_id, ts)
);
-- 50 devices x 900 seconds, in time order
INSERT INTO ahu_reading (device_id, ts, supply_temp, return_temp, zone_co2, fan_speed)
SELECT d, ts, 12 + d / 10.0, 22 + d / 10.0, 400 + d, 50 + d / 2.0
FROM generate_series('2026-10-01 00:00:00+00'::timestamptz,
'2026-10-01 00:14:59+00'::timestamptz,
interval '1 second') AS ts
CROSS JOIN generate_series(1, 50) AS d
ORDER BY ts, d;
VACUUM (ANALYZE);
SELECT round(pg_total_relation_size('ahu_reading')::numeric / (count(*) * 4), 1)
AS wide_total_bytes_per_value
FROM ahu_reading;
-- wide_total_bytes_per_value
-- ----------------------------
-- 29.5| Variant | Rows | Table bytes per row | Index bytes per row | What changed |
|---|---|---|---|---|
| Narrow: point_id, ts, value, quality | 180,000 | 60.3 | 33.1 | Baseline |
| Narrow, columns reordered: ts, value, point_id, quality | 180,000 | 52.2 | 32.6 | 8-byte columns first removes the 4-byte gap and the row fits in 48 bytes |
| Narrow with value as real (4 bytes) | 180,000 | 52.2 | 32.6 | Same saving as reordering, for the same reason; precision drops to about 6 significant digits |
| Wide: device_id, ts, four double values | 45,000 (180,000 values) | 76.6 | 40.6 | One header and one key per four values: 29.5 total bytes per value |
Index choices, measured
Three index observations from the same PGlite sample (illustrative measurements on a small sample compiled to 32-bit WebAssembly; not a production benchmark and not representative of your server), each with a caveat:
- Insert order affects B-tree size. The
(point_id, ts)primary key built up row by row while data arrived in time order measured 5,963,776 bytes on the sample; afterREINDEXit measured 5,701,632 bytes (about 4% smaller). Size the index as it will be in production, which means built incrementally. - BRIN on ts was tiny here. A BRIN index on
tsmeasured 24,576 bytes for 180,000 rows on the sample, because it stores one summary per block range rather than one entry per row. It only helps while rows stay roughly in time order on disk. - A B-tree on ts alone was small here for an artificial reason. It measured 1,302,528 bytes on the sample (about 7 bytes per row), because every timestamp repeats 50 times in this sample and B-tree deduplication stores duplicates compactly. Real gateways rarely stamp 50 points with the identical microsecond, so do not carry this figure over; measure with your real timestamps.
Narrow or wide is a modelling decision first
On the PGlite sample (an illustrative measurement), the wide table used about a third of the bytes per value, but it only works when a device reports the same set of values together at the same instant. BMS points with different intervals, change-of-value reporting or frequently added points fit the narrow shape better. The trade-offs are the subject of narrow vs wide time-series tables; this page only shows how to measure each candidate.
Measuring a partitioned table
One-second data in PostgreSQL is commonly partitioned by time (see choosing partition intervals). The parent of a partitioned table has no storage of its own, so pg_total_relation_size on it returns 0. Sum the leaf partitions from pg_partition_tree instead. The sample below has two daily partitions and 20 points of one-second data around midnight.
DROP TABLE IF EXISTS reading; -- replaces the narrow sample table from the first example
CREATE TABLE reading (
point_id integer NOT NULL,
ts timestamptz NOT NULL,
value double precision,
quality smallint NOT NULL DEFAULT 0,
PRIMARY KEY (point_id, ts)
) PARTITION BY RANGE (ts);
CREATE TABLE reading_2026_10_01 PARTITION OF reading
FOR VALUES FROM ('2026-10-01 00:00+00') TO ('2026-10-02 00:00+00');
CREATE TABLE reading_2026_10_02 PARTITION OF reading
FOR VALUES FROM ('2026-10-02 00:00+00') TO ('2026-10-03 00:00+00');
INSERT INTO reading (point_id, ts, value)
SELECT p, ts, 20 + sin(extract(epoch FROM ts) / 600.0)
FROM generate_series(1, 20) AS p
CROSS JOIN generate_series('2026-10-01 23:30:00+00'::timestamptz,
'2026-10-02 00:29:59+00'::timestamptz,
interval '1 second') AS ts
ORDER BY ts, p;
VACUUM (ANALYZE);SELECT pg_total_relation_size('reading') AS parent_total_bytes;
-- parent_total_bytes
-- --------------------
-- 0
SELECT relid, isleaf, level, pg_total_relation_size(relid) AS total_bytes
FROM pg_partition_tree('reading') ORDER BY level, relid::text;
-- relid | isleaf | level | total_bytes
-- --------------------+--------+-------+-------------
-- reading | f | 0 | 0
-- reading_2026_10_01 | t | 1 | 3424256
-- reading_2026_10_02 | t | 1 | 3424256
SELECT sum(pg_total_relation_size(relid)) AS all_partitions_bytes,
round(sum(pg_total_relation_size(relid)) / (SELECT count(*) FROM reading), 1) AS bytes_per_row
FROM pg_partition_tree('reading');
-- all_partitions_bytes | bytes_per_row
-- ----------------------+---------------
-- 6848512 | 95.1Step 5: extrapolate, with the assumptions in the query
Keep the measurement and the assumptions in one place so that anyone can change an assumption and re-run the estimate. The query below uses the 93.6 bytes per reading from the illustrative PGlite measurement above (a small sample on a 32-bit WebAssembly build, not representative of your server) and assumes 2,000 points at one reading per second with no gaps and a 30-day raw window; those three inputs are illustrative assumptions, not typical values. Everything it returns is an estimate built on an illustrative measurement and stated assumptions: it demonstrates the arithmetic and is not a capacity figure for any real server. Replace the 93.6 with the figure you measure on your own server.
WITH measured AS (
SELECT 93.6::numeric AS bytes_per_reading -- illustrative PGlite figure: replace with yours
), assumptions AS (
SELECT 2000 AS points, -- one-second points in scope
86400 AS readings_per_point_per_day, -- 1 s interval, no gaps
30 AS raw_days -- raw retention window
)
SELECT a.points * a.readings_per_point_per_day AS rows_per_day,
round(a.points * a.readings_per_point_per_day * m.bytes_per_reading / 1e9, 2) AS est_gb_per_day,
round(a.points * a.readings_per_point_per_day * m.bytes_per_reading * a.raw_days / 1e9, 1) AS est_gb_raw_window
FROM measured m CROSS JOIN assumptions a; rows_per_day | est_gb_per_day | est_gb_raw_window
--------------+----------------+-------------------
172800000 | 16.17 | 485.2Check the row rate before trusting the estimate
The largest error in most estimates is the row count, not bytes per row. "One second" is often a polling ceiling, not what arrives: some points report only on change, gateways drop samples under load, and some meters report every 15 minutes. If you have a day of real data, count actual rows per point per day and use that instead of 86,400. If you plan to keep one-second data only for a subset of points (for example chiller and AHU control loops for fault detection) and one-minute data for the rest, size the two groups separately.
Step 6: what the table size does not include
The measured figure is table plus indexes at rest. A server needs more than that. List each item below as an assumption with its own number; none of them can be read off the sample.
SELECT pg_wal_lsn_diff(
(SELECT lsn FROM lsn_mark WHERE label = 'after'),
(SELECT lsn FROM lsn_mark WHERE label = 'before')) AS wal_bytes_for_insert;
-- wal_bytes_for_insert
-- ----------------------
-- 29616512| Item | Why it uses space | How to size it |
|---|---|---|
| Write-ahead log (WAL) in pg_wal | Every insert is logged first. Checkpoints happen every 5 minutes or before max_wal_size (1 GB by default, a soft limit) is exceeded. With WAL archiving on, segments stay until archived. | Measure WAL per row on the sample (below), then size pg_wal for max_wal_size plus a backlog if archiving or a replica falls behind |
| Roll-up tables | Fifteen-minute or hourly aggregates kept for years | Measure bytes per roll-up row the same way; rows per day = points x buckets per day |
| Dead rows and free space | Updates and deletes leave dead row versions; standard VACUUM makes the space reusable but generally does not return it to the operating system | Append-only readings stay close to the measurement; add an allowance if you update readings or use a fillfactor below 100 |
| Maintenance headroom | VACUUM FULL writes a complete new copy of the table (REINDEX similarly builds a new index) before the old one is removed | Our inference from that: allow free space at least the size of the largest table or partition you would rebuild, plus its indexes |
| Replicas | Each physical replica holds a full copy, including indexes | Multiply by the number of copies |
| Backups and WAL archive | Base backups plus the WAL needed to replay from them | Backup retention x backup size, plus archived WAL for that period; see the backup article |
| Growth | More points and more sites over the life of the hardware | Plan for the point count you expect at the end of the period, not today's |
Reading the WAL figure
In this illustrative PGlite measurement (a small sample, single session, not representative of your server), the load wrote 29,616,512 bytes of WAL for 180,000 rows, about 164.5 bytes per row: more than the 93.6 bytes the rows occupy at rest. Part of that is likely to come from full-page images (our inference from the documentation, not separately measured): with full_page_writes on (the default), the first change to a data page after each checkpoint logs the whole page. The ratio depends on checkpoint timing, batch size and server settings, so treat it as a reason to measure WAL on your own server, not as a constant. It matters for pg_wal sizing, the WAL archive used for point-in-time recovery, and network traffic to replicas.
Compressed and columnar engines need their own measurement
Row-store arithmetic does not transfer to columnar engines. For example, the vendor reports in Tiger Data's hypercore documentation that converting TimescaleDB data to its columnstore includes compression of up to 98%. That is the vendor's figure, not reproduced here; what you get depends on how repetitive your values and timestamps are, how the data is segmented and ordered, and how long data stays in the uncompressed row store before conversion.
The method still applies: load a sample of real data (a day exported from an existing BMS historian is far more realistic than generated sine waves, which may compress better than real readings), let the engine compress it as it would in production, read the size the engine reports, then extrapolate. Keep the recent uncompressed window separate in the estimate, because it is stored at row-store size. For how compression works and what it costs, see time-series compression and columnar storage.
Worked example: one-second data for a plant room
An estate team wants one-second data for fault detection on 2,000 points (chillers, pumps, AHU control loops) and one-minute data for everything else, with 30 days of raw one-second data. The written sizing note might read:
- Illustrative measurement on the small PGlite sample (180,000 rows, PostgreSQL 18.6 row storage compiled to 32-bit WebAssembly; not a production benchmark and not representative of the production server, so a real note would replace it with a measurement on that server before sign-off): 93.6 bytes per reading including the primary key; about 164.5 bytes of WAL per reading during load.
- Assumed (illustrative values for this example): 2,000 points at 86,400 readings per day (to be replaced by a counted rate after the first week), 30-day raw window, one physical replica, backups kept for 14 days.
- Estimated from those assumptions and the illustrative measurement (placeholders until the server measurement replaces them): 16.17 GB per day and 485.2 GB for the raw window on the primary; the same again on the replica; roll-ups, pg_wal, the backup set and maintenance headroom listed as separate lines with their own assumptions.
- Decision trigger: if the counted row rate or retention grows enough that row storage becomes the dominant cost, re-run the measurement in a compressed engine before buying disk.
Limitations of this method
- Build differences. The sample ran on PGlite, a 32-bit WebAssembly build. Its heap measurement matched the 8-byte-alignment arithmetic above for these column types (136 rows per page), but the page layout documentation says user data alignment depends on the platform's MAXALIGN, and index and WAL sizes were not cross-checked the same way. Re-run the same script on the target server before signing off.
- Synthetic values. Fixed-width types (integer, timestamptz, double precision) take the same space whatever the value, so generated data is fine for row stores. Variable-width columns (text, numeric, jsonb) and every compressed engine need real values.
- Nulls. A row with any null value gains a null bitmap, which can change its padded size. Include nulls in the sample at the rate you expect.
- Time. A freshly loaded table has no dead rows or index churn. Re-measure after a few weeks in production and compare.
Related pages: retention and downsampling design for choosing the windows that drive this estimate, choosing partition intervals, and backup and restore for time-series data for the backup line items. To run the queries yourself, use the PostgreSQL playground or PostgreSQL in Docker.
Frequently asked questions
How do I calculate IoT data storage requirements?
Multiply rows per day by retention days by bytes per row, where bytes per row is measured on a sample loaded into your real schema (table plus indexes). Then add WAL, replicas, backups, roll-ups and headroom as separate, written assumptions.
How many bytes does one sensor reading take in PostgreSQL?
It depends on the schema, the platform and the data; there is no universal figure. As an illustrative measurement on the small PGlite sample on this page (PostgreSQL 18.6 compiled to 32-bit WebAssembly; not a production benchmark and not representative of your server), a narrow row of point_id, timestamptz, double precision and smallint with a primary key on (point_id, ts) took 93.6 bytes per reading: 60.3 for the row and 33.1 for the index. Reordering columns brought the row to 52.2 bytes on the same sample. Measure on your own server before using any figure.
Why does pg_total_relation_size return 0 for my table?
Because it is a partitioned table: the parent has no storage of its own. Sum pg_total_relation_size(relid) over pg_partition_tree('your_table') to get the size of all partitions.
How much storage does one-second data need compared with one-minute data?
Sixty times as many rows for the same points, so roughly sixty times the raw storage in a row store if every point really reports every second (an estimate that assumes the same bytes per row at both rates). Count the real row rate first; change-of-value reporting and dropped samples often make it lower.
Does compression change the calculation?
Yes. Columnar engines can store fewer bytes per reading (vendors report large reductions), but the ratio depends on your data. Measure a sample of real data in the engine you plan to use rather than applying a vendor percentage.
Sources
- PostgreSQL 18 documentation: System Administration Functions (database object size and partitioning information)
- PostgreSQL 18 documentation: Database Page Layout
- PostgreSQL 18 documentation: Determining Disk Usage
- PostgreSQL 18 documentation: B-Tree Indexes
- PostgreSQL 18 documentation: WAL Configuration
- PostgreSQL 18 documentation: Routine Vacuuming
- PostgreSQL 18 documentation: CREATE TABLE
- Tiger Data documentation: Understand hypercore
Checked 27 October 2026.
How we research these articles: our editorial method. Research-based from official documentation; SQL examples are run on a local database where the page says so. CodeWithSQL earns nothing from the vendors mentioned.