SensorRig

SQLite Rollups and Retention: Stop Your Sensor DB From Eating the SD Card

29 August 2026 · 6 min read

Hardware used in this build

  • Raspberry Pi 4 — 1-Wire master + villa collector
    Used in the running shed build; works reliably.
  • Raspberry Pi 5 (16GB) — Always-on host: serves this site + runs the agent
    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) — 1-Wire temp probes (house)
    Waterproof DS18B20 probe with stainless steel sleeve, includes 4.7kΩ resistor. Buy the 2-pack.
  • DHT11 — Indoor temp + humidity (long-run logging)
    Fine for 20-80 %RH yes/no questions; out of spec below 20 %RH. No standalone product page yet.
  • ESP32-C3 dev board — Shed node MCU
    The shed node MCU. Runs the AI_optimized_shed_TX.ino sketch. Seeed's own board.
  • HC-12 433 MHz wireless module — Radio link shed <-> Pi 4
    HiLetgo HC-12 433 MHz SI4438 module with antenna. Reliable for 100–1 000 m links.
  • ADS1115 16-bit I2C ADC breakout — Reads the two CT clamps (channels A2/A3) for whole-house current
    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) — Current sensing on both 120 V legs of the panel
    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 — Solar bus voltage + power
    Adafruit INA219 High Side DC Current Sensor Breakout. ±26 V, ±3.2 A, I2C. The one I use.

Full parts list & affiliate disclosure →

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.

MetricValue
Raw rows / 24 h~122,597 (~5,108/hr)
Peak second (measured)818 rows
WAL before nightly checkpoint4.3 MB
DB size (10-day run)139 MB
Raw rows (10-day run)1,258,105
5-min rollup rows227,109
Hourly rollup rows461,039
Raw retention (policy)30 days
5-min rollup retention730 days
Hourly rollup retentionforever

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:

What the comparison looks like

ApproachRaw windowDB size (projected, 30 d)Query on 2-yr trendSD wear risk
No rollups, keep allinfinite~500 MB+ (growing linearly)full scan, slowhigh (bloat amplifies writes)
5-min rollup, 30 d raw30 d~300 MB (bounded)rollup index, fastlow
Hourly rollup only7 d~180 MBrollup indexlow
RRDtool (parallel)fixed ringfixed ~2 MBfixed resolutionvery 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

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