SQLite Rollups and Retention: Stop Your Sensor DB From Eating the SD Card
Hardware used in this build
- Raspberry Pi 4
Used in the running shed build; works reliably. - Raspberry Pi 5 (16GB)
The always-on host for SensorRig. I run the 16GB board, but the Pi 5 is currently sold out — the Pi 4 4GB (linked) is plenty for DAQ work and this site. - DS18B20 (x8)
Waterproof DS18B20 probe with stainless steel sleeve, includes 4.7kΩ resistor. Buy the 2-pack. - DHT11
Fine for 20-80 %RH yes/no questions; out of spec below 20 %RH. No standalone product page yet. - ESP32-C3 dev board
The shed node MCU. Runs the AI_optimized_shed_TX.ino sketch. Seeed's own board. - HC-12 433 MHz wireless module
HiLetgo HC-12 433 MHz SI4438 module with antenna. Reliable for 100–1 000 m links. - ADS1115 16-bit I2C ADC breakout
HiLetgo ADS1115 16-bit 4-channel I2C ADC. Fine for relative load trends; 860 SPS is ~14 samples/cycle at 60 Hz, so harmonics alias. - Split-core CT clamp (x2)
JANSANE SCT-013-000 100 A split-core CT clamp. Works, but a split core that is not fully closed reads low silently. Calibration constant is empirically fitted. - INA219
Adafruit INA219 High Side DC Current Sensor Breakout. ±26 V, ±3.2 A, I2C. The one I use.
If you keep every raw row from six data sources forever, SQLite grows linearly and your queries eventually crawl. The fix isn’t a new server — it’s rollups + a retention policy, with a hard look at what actually wears an SD card.
The measured baseline
My Pi 4 logger writes continuously: basement temp/humidity (DS18B20 1-Wire + DHT11), per-room heating (5 Google Nest thermostats via the SDM API), solar shed node (ESP32-C3 over HC-12 433 MHz), whole-house power (CT clamps with ADS1115 ADC), an INA219 rail monitor, and a server rack. Six sources, one timeseries.db, WAL mode, synchronous=NORMAL, auto_vacuum=0.
Numbers pulled live from the DB (see the WAL performance post for the full table):
The parallel-run cutover that proved this setup is documented in the migration post and the parallel-run monitoring guide. The whole-house power numbers themselves come from the CT clamp energy monitor.
| Metric | Value |
|---|---|
| Raw rows / 24 h | ~122,597 (~5,108/hr) |
| Peak second (measured) | 818 rows |
| WAL before nightly checkpoint | 4.3 MB |
| DB size (10-day run) | 139 MB |
| Raw rows (10-day run) | 1,258,105 |
| 5-min rollup rows | 227,109 |
| Hourly rollup rows | 461,039 |
| Raw retention (policy) | 30 days |
| 5-min rollup retention | 730 days |
| Hourly rollup retention | forever |
That 122k/day rate is the real figure. Projected from it: a full 30-day raw window holds ~3.7M rows; without rollups, a year reaches ~44.7M raw rows. At ~72 bytes/row (139 MB ÷ 1.94 M total rows, measured from the 10-day run), that balloons to ~500 MB+ — and your 30-day query scans the full table.
The rollup method
I run a nightly cron 03:30 that does three things in one SQLite session, wrapped in BEGIN IMMEDIATE so the asyncio collector (FastAPI + Chart.js backend) never blocks:
-- 1) Aggregate last 5-minute window into rollup
INSERT INTO measurements_5m (ts, source, metric, avg_val, min_val, max_val, n)
SELECT
CAST(ts/300 AS INTEGER)*300,
source, metric,
AVG(value), MIN(value), MAX(value), COUNT(*)
FROM measurements
WHERE ts >= :window_start AND ts < :window_end
GROUP BY source, metric, CAST(ts/300 AS INTEGER);
-- 2) Delete aged raw rows
DELETE FROM measurements WHERE ts < :cutoff_30d;
-- 3) Truncate WAL (measured: 4.3 MB -> 0 bytes in one call)
PRAGMA wal_checkpoint(TRUNCATE);
The rollup table schema is deliberately boring — same source/metric shape, just with avg/min/max/n and a 300-second ts. Queries against the dashboard hit rollups for historical trends; raw detail is only needed for recent debugging or validation against the ADS1115 calibration data.
SD-card wear: the physics
Every insert writes pages. With WAL mode, writers append to -wal; at checkpoint those frames copy to the main DB. The measured WAL before truncate is 4.3 MB. Call it ~4.3 MB/day WAL growth, plus checkpoint copy (similar magnitude) and page-split overhead: conservatively ~5-8 MB of flash I/O per day.
A 128 GB consumer-grade TLC microSD (e.g., Samsung EVO Plus 128 GB, rated ~300 TBW per Samsung’s spec sheet) is a common choice. At 6 MB/day flash I/O, the card’s endurance rating dwarfs typical controller lifespans of a few years; wear is effectively irrelevant. The card fails of controller age, voltage sag, or power-cut corruption long before write endurance. (The same DS18B20 probes writing those rows are worth verifying as genuine before trusting a decade of data to them — three of seven in one of my batches were fakes, and the check is in how to spot a fake DS18B20.)
So the SD-card anxiety is mostly a proxy for “database bloat” — if you skip rollups, you trigger full-table scans and growing WAL, which increases write amplification and the chance of a bad checkpoint. The fix is the retention policy, not an SSD swap.
Still, if you want SD-card safety:
- Use a UPS (Pi 4 logger runs on a bench supply; a 10-second hold prevents WAL corruption during checkpoint).
- Keep
synchronous=NORMAL;FULLdoubles writes with almost no durability gain under WAL. - Monitor WAL size (
sqlite3 timeseries.db "PRAGMA page_count; PRAGMA wal_checkpoint(QUICK);") — quick checkpoint for monitoring avoids blocking the collector. If it climbs past 200 MB, the nightly cron failed. - Never run VACUUM on a large DB; it rewrites the whole file, causing write amplification and lock contention. Use incremental auto_vacuum if needed.
What the comparison looks like
| Approach | Raw window | DB size (projected, 30 d) | Query on 2-yr trend | SD wear risk |
|---|---|---|---|---|
| No rollups, keep all | infinite | ~500 MB+ (growing linearly) | full scan, slow | high (bloat amplifies writes) |
| 5-min rollup, 30 d raw | 30 d | ~300 MB (bounded) | rollup index, fast | low |
| Hourly rollup only | 7 d | ~180 MB | rollup index | low |
| RRDtool (parallel) | fixed ring | fixed ~2 MB | fixed resolution | very low, but no SQL |
I keep RRDtool running in parallel (legacy) for comparison. It stays fixed-size and fast, but impossible to JOIN against the Nest SDM device table or to query the ESP32-C3 shed node by source. SQLite + rollups is the practical split: operational simplicity with a SQL interface.
What went wrong / failure modes
- Silent rollup failure: The cron runs but the
INSERTfails (disk full, bad index). Raw rows pile up for weeks. Monitor withSELECT COUNT(*) FROM measurements WHERE ts > now()-86400. I check this in the weekly status script. - Deleting before rolling: I once deleted raw rows at 03:00 but the rollup ran at 03:30 — lost 30 minutes of resolution. The fix: rollup first, delete second, both in the same transaction.
- Checkpoint during high-write burst: The 818-row peak second can overlap with checkpoint, briefly blocking the collector. The nightly 03:30 window avoids this; never checkpoint during the ESP32-C3 radio burst (around 04:00 when the shed node reports).
- Not measuring WAL growth: Without the WAL-size check, a failed cron causes WAL to grow. At 4.3 MB/day, it takes weeks to matter — but once it crosses 500 MB, the SD card’s free space is the real problem.
- Misapplying vacuum: Running
VACUUMon the 139 MB DB takes minutes and rewrites the whole file, which is exactly the write amplification you wanted to avoid.
What’s unmeasured — fill in here
I have not directly measured the exact bytes written to flash per day averaged over 7 days on the Pi 4 logger; iotop or strace would provide that figure. The specific SD card TBW rating is not verified here — a typical consumer-grade TLC card is rated around 300 TBW per Samsung’s spec sheet, but the exact card used should be confirmed via smartctl or the packaging. The projected DB size at 30 days with rollups (~300 MB) has not been confirmed against a full 30-day run; it is derived from the measured 10-day data and may vary with sensor load. The migration from RRDtool that supplied the hourly history is described in Migrating RRDtool to SQLite on Raspberry Pi; the parallel-run discipline that caught the deletion-order bug is in Parallel-Run: How to Replace a Monitoring System Without Trusting It Yet.
Bottom line
SQLite WAL handles my six-source load easily. The risk isn’t write speed — it’s unbounded growth. Rollups (5-min/30-day + hourly/forever) cap the working set. At ~122k rows/day, the DB stays bounded; flash I/O is well below consumer card endurance. Keep the rollup cron healthy, check WAL size, and don’t confuse database bloat with SD-card death.
Related reading
- SQLite WAL Raspberry Pi Logger Performance: 1.25M Rows, 10 Days Zero Corruption
Real numbers from a Raspberry Pi 4 running SQLite in WAL mode as a 24/7 home sensor logger: 1.25M rows, 5108 writes/hour, 4 MB WAL, zero corruption after 10 days of continuous writes.
- Parallel-Run: How to Replace a Monitoring System Without Trusting It Yet
How I ran my old RRDtool logger and its SQLite replacement in parallel for months — why you tee the reads instead of double-polling, and how to set a real cutover test before a monitoring-system migration.
- DHT11 vs DHT22: 18 Months of Accuracy Data on a Raspberry Pi
DHT11 accuracy after 18 months of continuous logging on a Raspberry Pi: 26,318 rollup rows, 38% below the datasheet's 20 %RH floor, and where the part stops being usable.