Overview
The MH-Z19C is a self-contained NDIR (non-dispersive infrared) carbon dioxide sensor from Winsen that measures true CO2 concentration from 400 to 5000 ppm. Unlike resistive "air quality" sensors that react loosely to many gases, NDIR sensing works on physics: an infrared lamp shines through the air inside the gold-plated chamber, CO2 molecules absorb light at exactly 4.26 um, and the sensor measures how much light survives the trip. The result is a calibrated ppm reading you can log directly — no gas-specific calibration curves or baseline resistance math required.
The module outputs data two ways: a 3.3V-logic UART at 9600 baud with a simple 9-byte command protocol, and a PWM output whose duty cycle encodes the ppm value. It runs from a tightly regulated 5V supply (4.9-5.1V), draws about 40 mA on average with short lamp pulses reaching ~125 mA, and needs roughly a minute of preheat before readings settle. Automatic Baseline Correction (ABC) recalibrates the sensor's 400 ppm baseline every 24 hours, and the Hd pin gives you a hardware zero-calibration trigger when you want to do it manually.
With Arduino, ESP32, Raspberry Pi, and Raspberry Pi Pico all able to speak the UART protocol in a few lines of code, the MH-Z19C is the go-to sensor for CO2 monitors, smart ventilation triggers, classroom and office air dashboards, greenhouse controllers, and any project where "how fresh is this air, really?" is the question.
At a Glance
Specifications
| Parameter | Value |
| Sensing Principle | NDIR (non-dispersive infrared), CO2-specific |
| Measurement Range | 400 - 5000 ppm |
| Accuracy | ±(50 ppm + 5% of reading) |
| Supply Voltage | 4.9 - 5.1V DC (tightly regulated 5V) |
| Working Current | ~40 mA average, ~125 mA peak (IR lamp pulses) |
| UART Interface | 9600 baud, 8N1, 3.3V logic levels |
| PWM Output | ~1004 ms cycle, duty cycle proportional to ppm |
| Analog Output (AOT) | 0.4 - 2V DAC output (optional) |
| Preheat Time | < 1 minute |
| Response Time | T90 < 120 seconds |
| Calibration | ABC auto-baseline (24 h cycle) + manual zero via Hd pin or UART command |
| Operating Conditions | -10°C to +50°C, 0 - 95% RH non-condensing |
| Lifespan | > 10 years |
Pinout Diagram
The pins you will actually wire are Vin (5V), GND, Tx, and Rx — the UART pair carries the CO2 readings, with the sensor's Tx going to your board's RX. PWM is the alternative single-wire output if you prefer measuring duty cycle over reading serial data. Hd is the manual zero-calibration input: hold it low for more than 7 seconds while the sensor sits in fresh 400 ppm air and it re-zeros the baseline. On the far row, AOT is the 0.4-2V analog output, while SR and Ve are reserved by the factory — leave them unconnected.
Wiring Guide
Arduino Wiring
The Uno's hardware serial is tied to USB, so the sensor talks over SoftwareSerial on pins 2 and 3. Power comes from the Arduino's regulated 5V pin.
| MH-Z19C Pin | Arduino Pin | Details |
|---|---|---|
| Vin | 5V | |
| GND | GND | |
| Tx | D2 | SoftwareSerial RX |
| Rx | D3 | SoftwareSerial TX - via voltage divider |
ESP32 Wiring
The ESP32's second hardware UART on GPIO 16/17 matches the sensor's 3.3V logic perfectly — wire it straight in, with 5V power taken from VIN.
| MH-Z19C Pin | ESP32 Pin | Details |
|---|---|---|
| Vin | VIN (5V) | Sensor requires ~5V supply |
| GND | GND | |
| Tx | GPIO 16 (RX2) | 3.3V logic - direct |
| Rx | GPIO 17 (TX2) | 3.3V logic - direct |
Raspberry Pi Wiring
The sensor connects to the Pi's GPIO UART. Free the port first: run sudo raspi-config, open Interface Options > Serial Port, answer "No" to the login shell and "Yes" to the serial hardware, then reboot.
| MH-Z19C Pin | Raspberry Pi Pin | Details |
|---|---|---|
| Vin | Pin 2 (5V) | |
| GND | Pin 6 (GND) | |
| Tx | Pin 10 (GPIO 15, RXD) | 3.3V logic - direct |
| Rx | Pin 8 (GPIO 14, TXD) | 3.3V logic - direct |
Raspberry Pi Pico Wiring
UART0 on GP0/GP1 handles the sensor, with 5V taken from VBUS while the Pico is powered over USB.
| MH-Z19C Pin | Pico Pin | Details |
|---|---|---|
| Vin | VBUS (pin 40) | 5V from USB |
| GND | GND (pin 38) | |
| Tx | GP1 (pin 2, UART0 RX) | 3.3V logic - direct |
| Rx | GP0 (pin 1, UART0 TX) | 3.3V logic - direct |
Code Examples
All examples use the sensor's simple UART protocol directly — no libraries needed. The 9-byte read command is 0xFF 0x01 0x86 0x00 0x00 0x00 0x00 0x00 0x79, and the CO2 value comes back in bytes 2 and 3 of the response.
Arduino
// MH-Z19C CO2 Sensor - Arduino Example (raw UART protocol)
// Sensor Tx -> D2, Sensor Rx -> D3 (via divider), Vin -> 5V, GND -> GND
#include <SoftwareSerial.h>
SoftwareSerial co2Serial(2, 3); // RX = D2 (from sensor Tx), TX = D3
// Command: read CO2 concentration
const byte readCmd[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};
void setup() {
Serial.begin(9600);
co2Serial.begin(9600); // MH-Z19C fixed baud rate
Serial.println("Warming up (about 60 s after power-on)...");
}
void loop() {
byte response[9];
co2Serial.write(readCmd, 9);
co2Serial.setTimeout(500);
if (co2Serial.readBytes(response, 9) == 9 &&
response[0] == 0xFF && response[1] == 0x86) {
// Verify the checksum before trusting the data
byte checksum = 0;
for (int i = 1; i < 8; i++) checksum += response[i];
checksum = 0xFF - checksum + 1;
if (checksum == response[8]) {
int ppm = response[2] * 256 + response[3];
Serial.print("CO2: ");
Serial.print(ppm);
Serial.println(" ppm");
} else {
Serial.println("Checksum error - reading discarded");
}
} else {
Serial.println("No response - check wiring and 5V supply");
}
delay(5000); // The sensor updates slowly; 5 s polling is plenty
}
ESP32 (Arduino IDE)
// MH-Z19C CO2 Sensor - ESP32 Example (raw UART protocol)
// Sensor Tx -> GPIO 16 (RX2), Sensor Rx -> GPIO 17 (TX2), Vin -> VIN (5V)
HardwareSerial co2Serial(2); // UART2
const uint8_t readCmd[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};
void setup() {
Serial.begin(115200);
co2Serial.begin(9600, SERIAL_8N1, 16, 17); // baud, config, RX, TX
Serial.println("Warming up (about 60 s after power-on)...");
}
void loop() {
uint8_t response[9];
co2Serial.flush();
while (co2Serial.available()) co2Serial.read(); // clear stale bytes
co2Serial.write(readCmd, 9);
co2Serial.setTimeout(500);
if (co2Serial.readBytes(response, 9) == 9 &&
response[0] == 0xFF && response[1] == 0x86) {
uint8_t checksum = 0;
for (int i = 1; i < 8; i++) checksum += response[i];
checksum = 0xFF - checksum + 1;
if (checksum == response[8]) {
int ppm = response[2] * 256 + response[3];
Serial.printf("CO2: %d ppm\n", ppm);
}
} else {
Serial.println("No response - check wiring and 5V supply");
}
delay(5000);
}
Raspberry Pi (Python)
#!/usr/bin/env python3
# MH-Z19C CO2 Sensor - Raspberry Pi Example (raw UART protocol)
# Sensor Tx -> GPIO 15 (pin 10), Sensor Rx -> GPIO 14 (pin 8), Vin -> 5V
# Setup: sudo raspi-config (disable serial console, enable serial port)
# pip3 install pyserial
import time
import serial
READ_CMD = bytes([0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79])
ser = serial.Serial('/dev/serial0', baudrate=9600, timeout=1)
print('Warming up (about 60 s after power-on)...')
try:
while True:
ser.reset_input_buffer()
ser.write(READ_CMD)
response = ser.read(9)
if len(response) == 9 and response[0] == 0xFF and response[1] == 0x86:
checksum = (0xFF - (sum(response[1:8]) & 0xFF) + 1) & 0xFF
if checksum == response[8]:
ppm = response[2] * 256 + response[3]
print('CO2: {} ppm'.format(ppm))
else:
print('Checksum error - reading discarded')
else:
print('No response - check wiring and 5V supply')
time.sleep(5)
except KeyboardInterrupt:
print('Stopped by user')
finally:
ser.close()
Raspberry Pi Pico (MicroPython)
# MH-Z19C CO2 Sensor - Pico MicroPython Example (raw UART protocol)
# Sensor Tx -> GP1 (UART0 RX), Sensor Rx -> GP0 (UART0 TX), Vin -> VBUS
from machine import UART, Pin
import time
uart = UART(0, baudrate=9600, tx=Pin(0), rx=Pin(1), timeout=500)
READ_CMD = bytes([0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79])
print("Warming up (about 60 s after power-on)...")
while True:
# Clear any stale bytes, then request a reading
while uart.any():
uart.read()
uart.write(READ_CMD)
time.sleep_ms(200)
response = uart.read(9)
if response and len(response) == 9 and \
response[0] == 0xFF and response[1] == 0x86:
checksum = (0xFF - (sum(response[1:8]) & 0xFF) + 1) & 0xFF
if checksum == response[8]:
ppm = response[2] * 256 + response[3]
print("CO2:", ppm, "ppm")
else:
print("Checksum error - reading discarded")
else:
print("No response - check wiring and 5V supply")
time.sleep(5)