SensorRig

Migrating RRDtool to SQLite on Raspberry Pi: Keep Every Historical Data Point

3 August 2026 · 5 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.
  • RRDtool — Legacy round-robin time-series database
    Solid for fixed-retention rollups; migration to SQLite/WAL is the modern path. Keep running until replacement proves itself.
  • 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.

Full parts list & affiliate disclosure →

For two years my home sensors lived in RRDtool — one .rrd file per sensor, fixed-size round-robin archives, rrdtool fetch for everything. It worked, but it fought me on the one thing I actually wanted to do: ask questions of the data. You can’t JOIN RRD files. You can’t run a window function. You can’t easily say “show me every February where the mudroom dropped below 15°C.” So I migrated to SQLite in WAL mode (the same database the WAL post is about), using the parallel-run cutover I describe there. This is how I did it without throwing away a year of history.

The trap with RRDtool: history is the product

RRDtool’s genius is also its cage. A round-robin archive has a fixed size — old data falls off the end. That’s fine for a dashboard that only ever shows “last 24 hours.” It’s fatal if you later decide you wanted last year. By the time I wanted to query history, my RRDs had already been silently discarding it on a rolling window. (And a single .rrd often holds several metrics — basement.rrd carries both temp and hum — so “one file per sensor” understates the count.)

The migration’s real job wasn’t “move the data.” It was “rescue what’s left before it ages out, and never let that happen again.” SQLite has no fixed-size archive — I keep the hourly rollup forever by choice, not by constraint.

The original plan (and why it changed)

My first MIGRATION_PLAN.json specified DuckDB as the unified store, with a split architecture: an accumulator_unified.py ingestor and a dashboard_backend.py query layer. Clean separation of concerns.

I didn’t build that. Two reasons, both measured:

  1. DuckDB is overkill for one writer and one reader on a Pi. It shines for analytical workloads across many files; my workload is a trickle of inserts and some chart queries. SQLite WAL does that with zero extra processes.
  2. The schema was overengineered. The plan’s unified table had temperature_c, humidity_pct, pressure_pa, energy_cost_usd as separate typed columns. I went with a simpler EAV-ish (ts, source, metric, value) row instead — adding a sensor became an INSERT, not a migration.

So the plan was DuckDB + two services. The reality is SQLite + the existing FastAPI layer I already had. The plan was right to force me to think about schema; the reality is what actually runs. I’m documenting both because the pivot is the lesson, not an embarrassment.

The migration script: read-only, idempotent, parallel-safe

migrate_legacy.py is a one-time importer. Its three guarantees:

The source mapping is explicit — 19 legacy RRD data-source mappings across 16 .rrd files ported (the heat loop alone contributes eleven tempN.rrd files):

# (rrd_path, ds_name, new_source, new_metric)
RRDS = [
    ("/home/searay/rrdtool/basement/basement.rrd", "temp", "basement", "temp"),
    ("/home/searay/rrdtool/basement/basement.rrd", "hum",  "basement", "hum"),
    ("/home/searay/rrdtool/jakob/esp32.rrd",       "temp", "jakob",    "temp"),
    ("/home/searay/rrdtool/jakob/esp32.rrd",       "hum",  "jakob",    "hum"),
    ("/home/searay/rrdtool/jakob/esp32.rrd",       "press","jakob",    "press"),
    ("/home/searay/rrdtool/power/pwr1.rrd",        "curpwr","power",   "watts"),
    ("/home/searay/rrdtool/heat/energy_summary.rrd","energy","heat",   "load_delta"),
    ("/home/searay/rrdtool/heat/energy_summary.rrd","cost",  "heat",    "cost_rate"),
]
for n in range(1, 12):   # temp1..temp11 (9-11 kept per user decision)
    RRDS.append((f"/home/searay/rrdtool/heat/temp{n}.rrd", "temp", "heat", f"temp{n}"))

Plus the shed node’s shed_v2.rrd — eleven data sources (humidity, outside temp, lux, battery, solar, soil, pressure, relay state…) mapped into the shed source. The full list is 19 RRDs; the busiest is the heat loop with eleven DS18B20 probes.

Parallel-run: the cutover you can trust

The key to not losing sleep: don’t cut over. Run both. My collector config still has the legacy mirror mode:

[shed]
# parallel-run: mirror rows from the legacy RRDtool system written by old shed.service.
# At cutover set mode = "serial" (and stop old shed.service).
enabled = true
mode = "mirror"          # mirror | serial
interval = 60
serial_port = "/dev/ttyS0"

While mode = "mirror", the new collector also writes what the old one writes. Both systems accumulate. I compared them row-for-row until I trusted the new one, then flipped to serial and stopped the old shed.service. No gap, no reconciliation panic, no “did I lose Tuesday?”

The old RRDTool processes are still on the box as of this writing — rrdtool/shed/shed.py and rrdtool/power/curpwrai.py are running. Not because they have to be, but because the parallel-run doctrine says: keep the old system until you’ve personally verified the new one, then archive — never delete. The RRD files are the historical reference; they stay read-only.

What the migration actually bought

Measured from the live database after cutover:

FactValue
Legacy RRDs ported19 files
Migrated 1-hour rows (predating the new system)145,742
Oldest preserved hourly point2024-05-14
New system’s raw retention30 days (rolling)
New system’s hourly retentionforever
Freelist after migration + 10 days live0 (no corruption)

The headline: history that RRDtool was about to discard is now permanent. The 145,742 pre-cutover hourly rows reach back to May 2024 — two years I’d otherwise have watched age off the end of a round-robin archive.

What I’d tell past-me


All figures in this post were read from the running logger’s SQLite database and the live legacy RRDTool tree during writing. The migration script and config quoted here are the actual files in use.

Related reading