DHT11 After 18 Months of Continuous Logging: An Honest Accuracy Review
Hardware used in this build
- DHT11
Fine for 20-80 %RH yes/no questions; out of spec below 20 %RH. No standalone product page yet. - Raspberry Pi 4
Used in the running shed build; works reliably. - 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.
One DHT11 in my house has been logging on a one-minute interval into the same SQLite database as everything else since 25 January 2025. As of this morning that is 558 distinct days and 26,318 rows in my rollup table. A note on that table: despite being named measurements_1h it actually stores half-hour buckets — every timestamp lands on :00 or :30 — so 26,318 rows is roughly 13,160 hours, not 26,318 hours. I only noticed while writing this up, which is its own small lesson about trusting a table name. The question I actually needed answered was not “is the DHT11 accurate?” — the datasheet answers that — but “over 18 months in a real, unconditioned room, does this part ever tell me something I can act on?”
The short version: 10,090 of those 26,318 buckets, 38.3%, have a mean humidity below 20 %RH, which is outside the range the part is specified for. It reports those numbers with exactly the same confidence as the in-spec ones. That, not the ±5 %RH accuracy figure, is the real story.
This is not a bench review. I have no climate chamber and no reference hygrometer worth the name. What I have is 18 months of one-minute data and a willingness to say which parts of it are junk.
What the datasheet actually promises
Worth being precise, because most “DHT11 is garbage” posts are arguing against a spec the part never claimed to meet.
| Parameter | DHT11 (datasheet) | DHT22/AM2302 (datasheet) |
|---|---|---|
| Humidity range | 20–80 %RH | 0–100 %RH |
| Humidity accuracy | ±5 %RH | ±2 %RH (typ.) |
| Humidity resolution | 1 %RH | 0.1 %RH |
| Temperature range | 0–50 °C | −40–80 °C |
| Temperature accuracy | ±2 °C | ±0.5 °C |
| Min sampling interval | 1 s | 2 s |
I run DHT11 only. The DHT22 column is a datasheet figure quoted for context — I do not own DHT22s and will not pretend otherwise.
Two things the table does not tell you, both of which I can confirm from my own logged values:
My units report 0.1 °C steps, not 1 °C. Over the last 14 days of raw one-minute rows (19,572 samples) every temperature value is an exact multiple of 0.1, spanning 26.9–35.0 °C, with 83 distinct values. Every humidity value is an integer. So the temperature channel is finer than the classic 1 °C-resolution datasheet table implies, while humidity really is 1 %RH. If your DHT11 emits integer degrees, you have a different firmware revision than mine — check your own data before assuming.
Out-of-range is not graceful. The Mouser-hosted DHT11 datasheet warns that operating the part outside its stated working range “can result in 3%RH signal shift/discrepancy”. In my data the failure is far uglier than a 3 % shift. Below is the monthly mean humidity for the full 18 months:
| Month | mean %RH | Month | mean %RH |
|---|---|---|---|
| 2025-01 | 2.4 | 2025-11 | 27.0 |
| 2025-02 | 3.9 | 2025-12 | 11.2 |
| 2025-03 | 14.5 | 2026-01 | 4.8 |
| 2025-04 | 20.5 | 2026-02 | 1.5 |
| 2025-05 | 31.0 | 2026-03 | 5.6 |
| 2025-06 | 41.2 | 2026-04 | 14.1 |
| 2025-07 | 46.7 | 2026-05 | 27.5 |
| 2025-08 | 44.5 | 2026-06 | 36.6 |
| 2025-09 | 46.1 | 2026-07 | 40.8 |
| 2025-10 | 38.5 | 2026-08 (partial) | 45.4 |
A monthly mean of 1.5 %RH in February 2026 is not a measurement. Heated indoor air in a cold climate does get genuinely dry — single-digit RH is possible — but 276 buckets (1.0% of the record) came back with a mean under 1 %RH, and the lowest bucket means are on the order of 0.001 %RH. That is the sensor falling off the bottom of its transfer function, not a room. 17.2% of all buckets (4,538) are below 5 %RH.
The seasonal shape is real: the curve is smooth, repeats across two winters, and tracks the heating season. What is not real is the magnitude at the low end. I trust the direction below 20 %RH and nothing else.
The top end never came up: zero buckets above 80 %RH in the entire record, and zero temperature buckets outside 0–50 °C. So in my installation only one of the two range limits ever bites.
The cross-check I wanted, and why I could not run it
The plan was to difference the DHT11’s temperature channel against a co-located DS18B20 and watch the offset for drift over 18 months. A stable offset means the DHT11 has not walked; a trending offset means something has, and the DS18B20 (±0.5 °C over −10 to +85 °C, per its datasheet) is four times tighter, so the suspect would be obvious.
I could not run it, and the reason is worth stating rather than hiding: none of my eight DS18B20s are co-located with that DHT11. They are all on the heating system. Differencing anyway gives mean offsets from −3.1 to +7.0 °C with standard deviations of 5.9 to 13.4 °C — those numbers describe the distance between a room and a heating pipe, not sensor drift. Publishing them as a drift figure would be dishonest, so here they are as a negative result instead.
The query is still the right one once the hardware is right:
SELECT strftime('%Y-%m', ts, 'unixepoch', 'localtime') AS month,
COUNT(*) AS n,
ROUND(AVG(d.avg - w.avg), 3) AS mean_offset_c,
ROUND(MIN(d.avg - w.avg), 2) AS min_off,
ROUND(MAX(d.avg - w.avg), 2) AS max_off
FROM measurements_1h d
JOIN measurements_1h w ON w.ts = d.ts AND w.source = 'heat' AND w.metric = :ref
WHERE d.source = 'basement' AND d.metric = 'temp' AND d.avg > 0
GROUP BY month ORDER BY month;
Averaging does real work here. A single sample quantised to 1 %RH is coarse, but the mean of thousands of samples straddling a quantisation boundary recovers sub-step information — provided the underlying signal actually wanders across that boundary. Indoors it does, constantly. That is dithering, and it is free.
The fix is a hardware one, and it is on my list: mount a spare DS18B20 within a few centimetres of the DHT11, leave it a full month, then run the query above. The useful output is the trend in mean_offset_c from month to month, not its absolute value — the absolute value is just the thermal gradient between two points a few centimetres apart, and it will not be zero.
Reading it reliably on a Pi at all
Before accuracy, reliability. The DHT11’s single-wire protocol is timing-critical and Linux is not a real-time OS. Bit-banging it from userspace means the scheduler will occasionally preempt you mid-frame and you get a checksum failure. That is a broken sampling strategy, not a broken sensor.
My collector config sets max_retries = 15 on that channel — a number I arrived at by raising it until the failure rate stopped bothering me, not by measurement. It works: over the 14-day raw window, exactly 3 of 19,572 temperature samples and 3 of 19,572 humidity samples were logged as 0 after all retries were exhausted. On the full rollup record, 131 buckets have a mean humidity of exactly 0. So the retry loop is doing its job, and the residual failures are rare enough to filter downstream.
async def read_dht11(pin, retries=15, gap=2.1):
"""DHT11 needs >=1s between reads; 2.1s is a lazy safe margin."""
for attempt in range(retries):
try:
# note the order: this driver hands back (humidity, temp)
h, t = await asyncio.to_thread(dht_read, pin)
if h is None or t is None:
raise ValueError("bad frame")
if not (0 <= h <= 100) or not (-10 <= t <= 60):
raise ValueError(f"implausible: {t}C {h}%")
return t, h # ...and the collector wants (temp, humidity)
except (ValueError, RuntimeError):
if attempt == retries - 1:
return None, None
await asyncio.sleep(gap)
Two mistakes visible in my own data. First, that range gate passes 0.0, so exhausted reads got persisted as a plausible-looking zero instead of a NULL — which is why I have to write AND avg > 0 in every query forever. Return None and store NULL; do not let a sentinel value into a numeric column. (The logger underneath is SQLite in WAL mode, and the to_thread wrapper above is the same asyncio collector pattern I use for every bus.) Second, the better fix is architectural: my ESP32-C3 nodes read their sensors locally and ship validated frames over HC-12 433 MHz serial, so the microcontroller owns the timing and the Pi never sees a half-frame.
What went wrong
Zero as a sentinel. Covered above. 131 poisoned buckets and a permanent avg > 0 tax on every query. Cheapest possible mistake to avoid, and I made it anyway.
Self-heating. A DHT11 mounted close to a Pi reads warm. This is physics, not a fault — the board is a heat source and the sensor measures the air it is in. It biases both channels, because warming a parcel of air lowers its RH at constant absolute humidity. Everything I mount now is on a flying lead away from the board.
Winter dry-out below spec. 38.3% of my buckets are out of range and the part never says so. I now flag anything under 20 %RH rather than trusting it, and discard anything under 1 %RH outright.
Frozen output. A sensor that returns the same valid, plausible number forever looks exactly like a very stable room. On the raw 14-day window the longest identical run is 37 consecutive temperature samples and 107 consecutive humidity samples — about 37 and 107 minutes at a one-minute cadence. That is normal for a slow-moving room with 1 %RH steps, and it also tells me where to set the alarm: a staleness threshold below ~2 hours would fire on healthy data. Set it from your own run-length distribution, not from a guess.
Would I use one again
For “is this room damp” and “did the humidifier run”, yes. It is cheap, it is still working after 18 months, and ±5 %RH is fine for a yes/no question in the 20–80 %RH band.
For anything outside that band, no — and in my house that is 38% of the year. The DHT11’s problem is not that it is inaccurate. It is that it is coarse, range-limited, and silent about both. A known offset you can calibrate out. A sensor that quietly leaves its own spec for four months a winter, you cannot.
For temperature alone I reach for a DS18B20 instead — it reports its own bad reads via CRC, which is a different and much better failure mode; see what happened when mine threw CRC errors on a short cable.
Related reading
- An asyncio Collector Pattern for Mixed-Protocol Sensor Fleets
Why asyncio does not make 1-Wire faster, and the collector pattern I actually run: per-plugin loops, to_thread for blocking buses, and one batched SQLite writer.
- HC-12 vs WiFi for Remote Sensor Nodes: What 433 MHz Actually Buys You
Why I moved my outdoor ESP32-C3 sensor nodes off WiFi and onto HC-12 433 MHz serial radio, the settings that mattered, and the failure modes nobody warns you about.
- Migrating RRDtool to SQLite on Raspberry Pi: Keep Every Historical Data Point
How I migrated a two-year RRDtool logging setup to SQLite in WAL mode on a Raspberry Pi 4 — rescued 145,742 legacy rows, kept every historical data point, and used a parallel-run cutover to verify.