Documentation

MH-Z19C NDIR Infrared CO2 Sensor Module for Arduino, Raspberry Pi & ESP32 | ShillehTek Product Manual
Documentation / MH-Z19C NDIR Infrared CO2 Sensor Module for Arduino, Raspberry Pi & ESP32 | ShillehTek Product Manual

MH-Z19C NDIR Infrared CO2 Sensor Module for Arduino, Raspberry Pi & ESP32 | ShillehTek Product Manual

manualshillehtek

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

Measurement Range
400 - 5000 ppm CO2
Accuracy
±(50 ppm + 5% of reading)
Supply Voltage
4.9 - 5.1V DC
Interface
UART (9600, 3.3V) + PWM
Preheat Time
~1 minute
Key Pins
Vin, GND, Tx, Rx, PWM, Hd

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.

MH-Z19C NDIR CO2 sensor pin definition table and pinout diagram showing Vin, GND, PWM, Hd, Rx, Tx, and AOT pins

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
Warning: The MH-Z19C's UART runs at 3.3V logic. The Uno transmits 5V on D3, so drop it with a divider (1k from D3 to sensor Rx, 2k from sensor Rx to GND) to stay safely inside spec. The sensor's 3.3V Tx signal reads fine on a 5V Arduino input directly.
Tip: Power the sensor straight from the 5V pin, not through a breadboard rail fed by a weak supply — the IR lamp pulses to ~125 mA, and voltage dips below 4.9V show up as noisy or drifting readings.

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
Warning: Do not power the sensor from the 3V3 pin. The NDIR lamp needs a 4.9-5.1V supply; on 3.3V the module underperforms or fails to read at all. VIN (the USB 5V rail) is the right source.
Tip: Give the sensor 60 seconds after power-up before trusting values, and expect readings to step in 5 ppm increments — that is normal MH-Z19 behavior, not a wiring problem.

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
Note: The Pi's UART is 3.3V, exactly matching the sensor — no level shifting in either direction. If /dev/serial0 gives permission or garbage-data errors, double-check that the serial login console is disabled.

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
Warning: VBUS carries 5V only while USB is connected. On battery power, feed the sensor's Vin from a dedicated 5V regulator and tie its GND to the Pico's GND.

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

mhz19c_arduino.ino
// 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)

mhz19c_esp32.ino
// 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)

mhz19c_rpi.py
#!/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)

mhz19c_pico.py
# 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)

Frequently Asked Questions

What do the CO2 numbers actually mean for my room?
Outdoor air sits around 400-420 ppm. Indoors, under 800 ppm feels fresh, 800-1400 ppm suggests ventilation is falling behind, and readings above 1400 ppm usually mean it is time to open a window — many people report drowsiness and poor concentration at those levels. A simple, effective project is triggering a fan or notification when readings cross 1000-1200 ppm.
Why are my first readings 400 or 500 ppm and then they jump around?
The sensor needs about a minute of preheat after power-up while the IR lamp and detector stabilize, and full accuracy arrives over the following minutes (T90 response is under 120 seconds). Ignore the first minute of data, poll no faster than every few seconds, and expect values to move in 5 ppm steps — all of that is normal.
How do I calibrate the MH-Z19C?
Usually you do not have to: Automatic Baseline Correction assumes the sensor sees fresh ~400 ppm air at least once every 24 hours and re-zeros itself accordingly. For a manual zero, put the sensor in genuinely fresh outdoor air for 20+ minutes, then pull the Hd pin low for more than 7 seconds. Only calibrate in fresh air — zeroing indoors teaches the sensor that stale air is "400 ppm" and skews every future reading low.
Can I power it from 3.3V since the UART is 3.3V?
No — those are two different things. The power supply on Vin must be 4.9-5.1V for the NDIR lamp to run correctly, while the Tx/Rx data lines are 3.3V logic. So: 5V to Vin, data lines straight to 3.3V boards (ESP32, Pi, Pico), and a simple divider on the sensor's Rx line when driving it from a 5V Arduino.
Should I use the UART or the PWM output?
UART is the better choice for almost everyone: it returns exact ppm values with a checksum, and every platform on this page has a spare serial port. PWM is handy when you have no free UART — measure the high time within the ~1 second cycle and compute ppm from the duty cycle — but it is more timing-sensitive and slightly less precise. The AOT analog output (0.4-2V) exists too, though it is rarely used in maker projects.
What are the SR and Ve pins?
They are reserved factory pins — leave them unconnected. Everything a project needs is on Vin, GND, Tx, Rx, PWM, and (optionally) Hd for manual zero calibration and AOT for the analog output.
How is this different from an MQ-135 "air quality" sensor?
The MQ-135 is a heated metal-oxide resistor that responds loosely to a cocktail of gases and only estimates CO2 after careful per-unit calibration. The MH-Z19C measures CO2 itself by infrared absorption, arrives factory calibrated, and outputs real ppm numbers. For actual CO2 monitoring, NDIR is the tool; the MQ-135 is better treated as a general "something changed in the air" detector.

Related Tutorials