Migrating RRDtool to SQLite on Raspberry Pi: Keep Every Historical Data Point
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. - RRDtool
Solid for fixed-retention rollups; migration to SQLite/WAL is the modern path. Keep running until replacement proves itself. - 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.
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:
- 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.
- The schema was overengineered. The plan’s unified table had
temperature_c,humidity_pct,pressure_pa,energy_cost_usdas separate typed columns. I went with a simpler EAV-ish(ts, source, metric, value)row instead — adding a sensor became anINSERT, 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:
- Read-only on legacy data. It only ever calls
rrdtool fetch— never writes to an.rrd. If the migration explodes, the old system is untouched. - Idempotent. Every insert is
INSERT OR IGNORE. Run it twice, get the same result. This matters because I ran it while both systems were live. - Resolution-aware. RRD files store multiple RRA resolutions (e.g. 5-minute
and 1-hour). The script fetches at each resolution and writes to the matching
rollup table (
measurements_5m/measurements_1h).
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:
| Fact | Value |
|---|---|
| Legacy RRDs ported | 19 files |
| Migrated 1-hour rows (predating the new system) | 145,742 |
| Oldest preserved hourly point | 2024-05-14 |
| New system’s raw retention | 30 days (rolling) |
| New system’s hourly retention | forever |
| Freelist after migration + 10 days live | 0 (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
- Migrate sooner. The longer RRDtool runs, the more history is already gone. The rescue window is the round-robin length, not “whenever.”
- Keep the importer idempotent and read-only. I re-ran
migrate_legacy.pythree times during testing.INSERT OR IGNOREmeant I never had to wonder if a re-run doubled anything. - Don’t over-architect the target. My DuckDB two-service plan was correct on paper and wrong in practice. SQLite + the API I already had shipped faster and runs lighter. Plan to think, then build the simpler thing that satisfies the plan’s intent.
- Parallel-run beats cutover every time. The
mirrormode cost me a few duplicate writes for a week. That’s cheap insurance against a silent data gap.
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
- 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.
- 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.
- SQLite Rollups and Retention: Stop Your Sensor DB From Eating the SD Card
How rollup tables and a retention policy keep a Raspberry Pi sensor SQLite database from growing unbounded — and why SD-card wear is rarely the real problem.