SensorRig

SQLite WAL Raspberry Pi Logger Performance: 1.25M Rows, 10 Days Zero Corruption

3 August 2026 · 7 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.

Full parts list & affiliate disclosure →

I run a Raspberry Pi 4 as a 24/7 home sensor logger — basement temp/humidity, per-room heating (five Google Nest thermostats polled through the SDM API), a solar shed node, whole-house power, and a server rack. That’s six data sources writing continuously, gathered by the asyncio collector pattern and landed in one SQLite database. I needed a database that:

After parallel-running it against my old RRDtool setup, I landed on SQLite in WAL mode. This post is the measured case for it, drawn from the live database on the logger right now.

The numbers (pulled live from the running logger)

All figures below are from timeseries.db on the Pi 4, queried with the Python sqlite3 module. No estimates. For how I keep the DB from eating the SD card — rollups, retention, and why SD-card wear is rarely the real problem — see the companion post on SQLite rollups and retention.

MetricValue
Journal modeWAL
synchronousNORMAL
Total raw rows (measurements)1,258,105
Raw rows written in last 24 h122,597 (~5,108/hr)
5-minute rollup rows227,109
Hourly rollup rows461,039
Hourly history span810+ days (back to 2024-05-14 — includes migrated legacy data)
Main DB size139 MB
WAL file size4.3 MB (before checkpoint)
-shm size32 KB
Busiest single second818 rows
freelist_count after 10 days0 (no fragmentation)
auto_vacuum0 (not needed — WAL + rollup deletes keep it clean)

Why WAL, not the default rollback journal

The default SQLite journal (DELETE mode) writes the whole transaction to a rollback file, then commits by deleting it. Under a power cut mid-write, recovery is a replay. It works — but every commit touches the main database file, which means readers and writers block each other.

WAL mode flips it:

For a logger, that’s the whole game: the collector is always writing, and I’m always querying charts. They don’t step on each other.

The measured proof: with 818 rows landing in a single second and ~5,100 writes per hour sustained, the live site’s FastAPI layer was still serving chart queries without latency complaints. WAL is why.

The checkpoint question

WAL has one gotcha: the -wal file grows until a checkpoint. If it never checkpointed, a 120-day run would have a gigantic WAL. My setup runs a nightly rollup (cron 03:30) that does an explicit:

PRAGMA wal_checkpoint(TRUNCATE);

I ran that by hand during measurement: WAL went 4.3 MB → 0 bytes in one call. The nightly job keeps it bounded without me thinking about it.

If you’d rather not run a cron, SQLite auto-checkpoints at ~1000 pages of WAL (wal_autocheckpoint default) — fine for low-volume setups. At my write rate I prefer the explicit nightly truncate so the WAL never surprises me.

Retention without bloat: rollups + deletes

Raw data is kept 30 days, then deleted. The 5-minute rollup is kept 730 days, the hourly rollup forever. That’s why the main DB is only 139 MB despite 1.25M rows — the raw table is a sliding 30-day window, not an infinite growth.

This is the part that makes SD-card life bearable. Without rollups, 122k rows/day would hit ~45M rows/year and the DB would balloon. With them, the working set stays small and the historical detail I actually look at (hourly trends over two years) lives in 461k compact rows.

The schema (it’s boring on purpose)

CREATE TABLE measurements (
    ts     INTEGER NOT NULL,   -- unix epoch seconds
    source TEXT    NOT NULL,   -- basement|heat|jakob|shed|power|server
    metric TEXT    NOT NULL,   -- temp, hum, temp1..temp8, watts...
    value  REAL    NOT NULL
);
CREATE INDEX idx_meas ON measurements(source, metric, ts);
CREATE INDEX idx_meas_ts ON measurements(ts);

One wide-ish table, two indexes. No partitioning, no sharding, no extensions. The “EAV-ish” shape (source/metric/value) means adding a new sensor is an INSERT, not a migration. The cost is that a single sensor’s full history is scattered — but the rollup tables materialize exactly the queries the charts run, so that cost never shows up in practice.

Where the writes actually come from

Per-source raw-row counts over the 10-day raw window:

SourceRaw rows
shed (solar ESP32-C3)966,185
heat147,570
jakob (room)43,896
server41,384
power29,564
basement29,516

The shed node alone is 77% of all writes — and every one of those rows arrives over the HC-12 433 MHz radio link from the ESP32-C3 out in the shed, which reads its own DHT22 and ships them back as a CSV burst. The eight DS18B20 probes, the INA219, and one of the DHT11 are not on that link: they’re wired straight to the Pi 4’s own GPIO (1-Wire and I2C) as a separate local bus, landing in the same database under different sources. So the logger absorbs two independent firehoses — the Pi’s local sensor bus and the radio link from the shed — and the design is built to take both without flinching.

The framed stream from the shed runs through the HC-12 packet-loss retry protocol, which is the application-layer framing the collector uses to validate frames before they reach SQLite.

The migration angle (why this isn’t my first rodeo)

The hourly table reaches back to 2024-05-14 — includes migrated legacy data — but the raw table only goes back 10 days. The gap is the story: I migrated two years of legacy RRDtool history into SQLite (migrate_legacy.py) and kept it as the permanent hourly record, while letting the detailed raw data age out on a 30-day window. Old system’s history preserved; new system’s operational simplicity. That’s the parallel-run migration pattern, and it’s the same one I’d use to retire RRDtool anywhere. The raw table’s 10-day window and the nightly rollup that keeps it bounded are documented in the SQLite rollups and retention post.

Raw data grows about 122k rows a day. Without a retention policy that data eventually dominates the card and the queries slow down — see SQLite rollups and retention: stop your sensor DB from eating the SD card for the rollup tables and 30-day window I run on top of this WAL setup.

Should you use SQLite WAL for your Pi logger?

For a single-node, single-board logger: yes, almost certainly. The alternatives I considered:

SQLite WAL is a file. It’s in the repo, it’s on the SD card, it checkpoints itself, and it has not corrupted once across the run (freelist 0, zero repair events). For a home logger that’s the right complexity/robustness trade.

The one case where I’d reach for something else: if you’re aggregating many Pis or pushing past ~50k writes/sec, go straight to a time-series server. At 5k/hour, SQLite isn’t even warm.

What I’d do differently

If I started over: set PRAGMA wal_autocheckpoint lower (e.g. 200 pages) so the WAL self-bounds even if the nightly cron ever fails — belt and suspenders. The nightly TRUNCATE is enough in practice, but a failed cron shouldn’t be the only thing keeping the WAL small. Everything else — the schema, the rollup retention, synchronous=NORMAL — I’d keep exactly as is.


All numbers in this post were queried live from the running logger’s SQLite database during writing. No figures are estimated.

Related reading