Components · 15 min read

MQ135 Arduino Tutorial: Air Quality Sensor, Calibration and PPM

MQ135 Arduino guide: which gases it detects, 24–48 h burn-in, R0 calibration and the ppm formula, can it measure CO2, fan control, ESP32 logging vs CCS811.

MQ135 Arduino projects are the first stop for anyone who wants a cheap, simple way to keep an eye on how clean the air in a room is. The MQ-135 is a heated metal-oxide gas sensor that reacts to gases such as ammonia, nitrogen oxides, benzene, alcohol and smoke. In hobby kits it usually comes as a small module with a comparator chip and an adjustment potentiometer; it connects to the Arduino through its analog output and is read with a single analogRead(). But turning the number this sensor gives you into a meaningful measurement takes a bit more knowledge than most tutorials explain.

In this guide you'll learn what goes on inside the sensor, how sensitive it is to which gases and why it needs 24–48 hours of heating before its first real use. Then we'll build the ppm calculation step by step using the load resistor (RL), sensor resistance (Rs) and reference resistance (R0), do an R0 calibration in clean air and give an honest answer to the most common question: does the MQ-135 really measure CO2? Finally, we'll run a ventilation fan automatically, log readings with an ESP32 to a time-stamped file, write MicroPython code for the Raspberry Pi Pico and compare the MQ-135 with the CCS811 and NDIR CO2 sensors.

MQ-135 air quality sensor module
MQ-135 module: heated metal-oxide sensor, LM393 comparator, sensitivity potentiometer and VCC, GND, DO, AO pins.

What is the MQ-135 and how does it work?

Behind the stainless-steel mesh, the surface of a small ceramic tube is coated with tin dioxide (SnO2), and a heater wire running through it heats this layer to a few hundred degrees Celsius. In clean air, oxygen molecules cling to the SnO2 surface, trap free electrons in the material and the layer's resistance stays high. When a reducing gas such as ammonia, alcohol or benzene enters the air, it reacts with the oxygen on the surface, the electrons are released and the resistance drops. The denser the gas, the lower the resistance. The module puts this resistance in a voltage divider with a load resistor, so the voltage on the AO pin rises as the gas increases.

FeatureValue
Sensing materialTin dioxide (SnO2), heated ceramic tube
Heater voltage5 V ± 0.1 V
Heater resistance and current33 Ω ± 5%, about 150 mA
Heater powerUp to 800 mW
Detection rangeNH3 10–300 ppm, benzene 10–1000 ppm, alcohol 10–300 ppm
Initial preheatMore than 24 hours
Operating conditions−10 to 45 °C, relative humidity below 95%
Module outputsAO (analog) and DO (digital threshold via LM393)

Which gases does the MQ-135 detect?

The MQ-135 is not a selective sensor that measures one gas; it reacts at the same time to many gases that react with the heated SnO2 surface and can't tell them apart. The manufacturer describes it as especially sensitive to ammonia, sulphur compounds and benzene-type vapours; it also responds clearly to alcohol, smoke and nitrogen oxides. That's why it's best to think of the MQ-135 as an "air pollution indicator": when the value goes up it tells you something in the air has changed, but not what. For natural gas and LPG leaks, the MQ-2 gas sensor, designed for that job, is a much better fit.

GasWhere does it come from at home?MQ-135 response
Ammonia (NH3)Cleaning products, cat litter, fertiliserHigh
Benzene, toluene and solventsPaint, thinner, glue, cigarette smokeHigh
Alcohol vapourHand sanitiser, cologne, perfumeHigh
SmokeCooking, cigarettes, candlesMedium
Nitrogen oxides (NOx)Gas stove, exhaust fumesMedium
Carbon dioxide (CO2)Human breath, closed and crowded roomsWeak, indirect

Does the MQ-135 measure CO2?

The short answer: no, not really; it estimates it. The MQ-135 datasheet includes a CO2 curve, and most libraries online calculate a "CO2 ppm" value from it. But CO2 is a chemically stable gas and reacts only very weakly with the SnO2 surface. In a closed room, as people breathe, humidity and the volatile organic compounds given off by the body rise along with CO2, and those are what the MQ-135 really reacts to. So the "CO2" the sensor shows is actually an estimate based on the room's overall air pollution. The easiest proof: open a bottle of cologne next to it and watch the "CO2" value shoot up to 5000 ppm.

That doesn't mean the MQ-135 is useless. It works very well for relative decisions like "the room is getting stuffy, open the window" or "there's smoke in the kitchen, turn on the extractor". If you need the real CO2 value in a classroom, office or bedroom, though, you should use one of the NDIR CO2 sensors from the comparison at the end of this guide. The table below sums up what the readings of a real CO2 meter mean; you can interpret the MQ-135's estimates with the same ranges.

CO2 (ppm)What does it mean?
400–450Clean outdoor air
450–800Well-ventilated indoor space
800–1000Acceptable, think about ventilating
1000–1500Stuffy air, concentration may drop; ventilate
1500–2000Headaches and drowsiness may appear
50008-hour workplace exposure limit

MQ-135 Arduino wiring

Module pinArduino UnoESP32Raspberry Pi Pico
VCC (includes the heater)5VVIN (5 V from USB)VBUS (5 V from USB)
GNDGNDGNDGND
AO (analog output)A0GPIO 34, through a 10 kΩ / 20 kΩ dividerGP26 (ADC0), through a 10 kΩ / 20 kΩ divider
DO (digital threshold, optional)D2Any GPIO through a dividerAny GP pin through a divider
The MQ-135's heater alone draws about 150 mA and must have 5 V; powered from the 3V3 pin of an ESP32 or Pico, the sensor won't get hot enough and its readings will be meaningless. When the module runs on 5 V, the AO output can rise to 5 V, which damages the 3.3 V ADC input of the ESP32 and Pico. Build a 10 kΩ + 20 kΩ voltage divider between AO and GND and connect the midpoint to the ADC pin: 5 V × 20 / 30 = 3.33 V. You can try other resistor pairs with the voltage divider calculator. A running sensor gets too hot to hold; don't touch its mesh.

Preheating: the 24–48 hour burn-in and the daily warm-up

MQ series sensors have two separate warm-ups, and people often confuse them. The first is the initial preheat (burn-in): moisture and contamination build up on the surface of a brand-new sensor or one that has sat unused in storage for a long time. The manufacturer asks for the sensor to be powered for more than 24 hours before standard measurements; in practice, running it continuously for 24–48 hours lets you watch the values drift slowly and finally settle. If you do the R0 calibration before this period is over, all your readings will drift within a few days. The second is the daily warm-up: a sensor that has completed its burn-in still needs a few minutes every time it's switched on for the heater to reach equilibrium; the values in the first minute are clearly too high or too low. That's why the code includes a wait of at least 2–3 minutes; for precise measurements, waiting 10–20 minutes is better.

Rs, RL and R0: how do you calculate ppm with the MQ-135?

Inside the module, the sensor resistance Rs and the board's load resistor RL are in series and share the 5 V. The voltage on the AO pin is Vout = 5 × RL / (Rs + RL). Turn this formula around and you get the sensor's current resistance from the voltage you measured: Rs = RL × (5 − Vout) / Vout. On the Arduino Uno, the ADC uses the same 5 V as its reference, so you don't even need to convert to volts: Rs = RL × (1023 − raw) / raw. Rs on its own isn't meaningful, because the resistance varies a lot from one sensor to the next. What matters is the ratio of Rs to its value in a known air, called R0.

The curves in the datasheet relate the Rs/R0 ratio to gas concentration and are almost straight lines on log-log axes. That means they can be expressed with a power function: ppm = A × (Rs/R0)^−B. The coefficients that popular MQ-135 libraries use for the CO2 curve are A = 116.602 and B = 2.769. To find R0, you put the sensor in clean outdoor air; since outdoor CO2 is about 420 ppm today, you turn the formula around and calculate R0 = Rs × (420 / A)^(1/B) ≈ Rs × 1.59. Remember that these coefficients are different for other gases and that the result is only an estimate.

One of the small SMD resistors on the back of the module is RL, and the code printed on it tells you its value: 102 = 1 kΩ, 103 = 10 kΩ, 203 = 20 kΩ. We explain these codes in SMD resistor codes. Entering the wrong RL doesn't change the ppm result, because R0 is calculated with the same RL and the ratio stays the same; only the Rs and R0 numbers you see on screen are scaled. But if RL is very small (1 kΩ), the AO voltage stays very low in clean air and the ADC resolution suffers; if you see the values jittering, averaging is a good fix.

Step-by-step MQ-135 R0 calibration

  1. Connect the module's VCC to 5V, GND to GND and AO to A0, and complete the initial preheat by running the sensor non-stop for 24–48 hours.
  2. Read the code on the load resistor on the back of the board and set RL_KOHM in the code below to match.
  3. Take the circuit out to clean outdoor air, such as a balcony or an open window, away from traffic, cigarettes and the kitchen.
  4. Upload the calibration code, open the Serial Monitor at 9600 baud and wait at least 10 minutes.
  5. Once the average R0 on screen hardly changes for several minutes, write it down; that is your sensor's R0.
C++ (Arduino IDE)
1const byte MQ_PIN = A0;                 // MQ-135 AO
2const float RL_KOHM = 1.0;              // karttaki yük direnci: 102 = 1, 103 = 10 / load resistor on the board
3const float PARA = 116.6020682;         // CO2 eğrisi katsayıları / CO2 curve coefficients
4const float PARB = 2.769034857;
5const float CLEAN_AIR_PPM = 420.0;      // dış havadaki CO2 / CO2 in outdoor air
6
7float r0Average = 0;
8
9float readRs() {
10  long sum = 0;
11  for (int i = 0; i < 50; i++) {        // 50 ölçüm, yaklaşık 1 saniye / 50 readings, about 1 second
12    sum += analogRead(MQ_PIN);
13    delay(20);
14  }
15  float raw = sum / 50.0;
16  if (raw < 1) raw = 1;                 // sıfıra bölmeyi önle / avoid dividing by zero
17  return RL_KOHM * (1023.0 - raw) / raw;   // Rs = RL × (Vc − Vout) / Vout
18}
19
20void setup() {
21  Serial.begin(9600);
22  Serial.println(F("Temiz havada en az 10 dk bekle / wait at least 10 min in clean air"));
23}
24
25void loop() {
26  float rs = readRs();
27  float r0 = rs * pow(CLEAN_AIR_PPM / PARA, 1.0 / PARB);    // R0 = Rs × (420 / A)^(1/B)
28  if (r0Average == 0) r0Average = r0;
29  r0Average = r0Average * 0.95 + r0 * 0.05;                // yavaş ortalama / slow running average
30
31  Serial.print(F("Rs = "));
32  Serial.print(rs, 2);
33  Serial.print(F(" kOhm   R0 = "));
34  Serial.print(r0, 2);
35  Serial.print(F("   ortalama / average R0 = "));
36  Serial.println(r0Average, 2);
37}

Every second the code averages 50 readings, calculates Rs and the current R0 from them, then adds this value to a slow running average. The running average stops a brief whiff of exhaust carried by the wind from spoiling the result. The R0 you find belongs only to that sensor and that RL value; if you swap the sensor, calibrate again. Over months, as the sensor ages, R0 drifts slowly, so for projects that run for a long time it's a good habit to repeat the calibration every few months.

Measurement code: estimated ppm and air quality level

Once you've found R0, you put it into the measurement code. The code first waits 3 minutes for the warm-up, then every 2 seconds prints the Rs/R0 ratio, the estimated ppm and an air quality label based on the table above to the Serial Monitor. In clean air the ratio is around 0.6; the smaller it gets, the more gas there is in the air. To test it, put a few drops of cologne on a cotton ball near the sensor: within seconds the ratio drops and the ppm rises, and when you take the cotton away the values return to normal within a few minutes.

C++ (Arduino IDE)
1const byte MQ_PIN = A0;
2const float RL_KOHM = 1.0;              // kalibrasyondaki RL ile aynı / same RL as in calibration
3const float R0_KOHM = 3.6;              // kendi kalibrasyon sonucunla değiştir / replace with your calibration result
4const float PARA = 116.6020682;
5const float PARB = 2.769034857;
6const unsigned long WARMUP_MS = 180000; // 3 dk ısınma / 3 min warm-up
7
8float readRs() {
9  long sum = 0;
10  for (int i = 0; i < 20; i++) {
11    sum += analogRead(MQ_PIN);
12    delay(10);
13  }
14  float raw = sum / 20.0;
15  if (raw < 1) raw = 1;
16  return RL_KOHM * (1023.0 - raw) / raw;
17}
18
19const __FlashStringHelper* airLabel(float ppm) {
20  if (ppm < 800)  return F("Iyi / good");
21  if (ppm < 1000) return F("Orta / moderate");
22  if (ppm < 1500) return F("Havalandir / ventilate");
23  return F("Kotu / poor");
24}
25
26void setup() {
27  Serial.begin(9600);
28  Serial.println(F("Isiniyor / warming up..."));
29  while (millis() < WARMUP_MS) delay(100);
30}
31
32void loop() {
33  float ratio = readRs() / R0_KOHM;           // gaz arttıkça küçülür / falls as gas rises
34  float ppm = PARA * pow(ratio, -PARB);       // tahmini CO2 eşdeğeri / estimated CO2 equivalent
35
36  Serial.print(F("Rs/R0 = "));
37  Serial.print(ratio, 2);
38  Serial.print(F("   ~"));
39  Serial.print(ppm, 0);
40  Serial.print(F(" ppm   "));
41  Serial.println(airLabel(ppm));
42  delay(2000);
43}

How do temperature and humidity affect the MQ-135?

The resistance of metal-oxide sensors depends not only on gas but also on room temperature and humidity. According to the datasheet curve, in the same air the Rs/R0 ratio falls noticeably when the temperature rises from 20 °C to 40 °C, and it also falls as humidity increases; the sensor reads this as "more gas". The result is ppm values that climb by themselves in a room that gets afternoon sun or in a bathroom after a shower. There are two ways to reduce this: calibrate at a temperature and humidity close to where you'll use the sensor, or measure temperature and humidity with a DHT22 or DHT11 and apply the correction formula from the libraries. Functions like getCorrectedPPM(temperature, humidity) in ready-made MQ135 libraries do exactly that. If you want to measure temperature and humidity more accurately than a DHT11 does, you can use a BME280 sensor.

Automating a ventilation fan

The MQ-135's most useful job is switching on a fan or extractor by itself when the air gets stale. Two details matter here. The first is hysteresis: the fan switches on above 1000 ppm but only switches off once the value drops below 800 ppm; with a single threshold, the relay would click every second while the value wobbles around it. The second is a minimum run time: once the fan is on, it runs for at least 2 minutes so the air in the room really gets replaced. The code also smooths the readings so that momentary spikes don't trigger the fan.

C++ (Arduino IDE)
1const byte MQ_PIN = A0;
2const byte FAN_PIN = 7;                    // röle modülü IN / relay module IN
3const bool RELAY_ACTIVE_LOW = true;        // çoğu modül LOW ile çeker / most modules switch on with LOW
4const float RL_KOHM = 1.0;
5const float R0_KOHM = 3.6;                 // kendi kalibrasyon sonucun / your calibration result
6const float PARA = 116.6020682;
7const float PARB = 2.769034857;
8const float FAN_ON_PPM = 1000;             // bunun üstünde fanı aç / fan on above this
9const float FAN_OFF_PPM = 800;             // bunun altında fanı kapat / fan off below this
10const unsigned long MIN_RUN_MS = 120000;   // fan en az 2 dk çalışır / fan runs at least 2 min
11const unsigned long WARMUP_MS = 180000;    // 3 dk ısınma / 3 min warm-up
12
13bool fanOn = false;
14unsigned long fanStart = 0;
15float smoothPpm = 0;
16
17void setFan(bool on) {
18  fanOn = on;
19  if (on) fanStart = millis();
20  bool level = RELAY_ACTIVE_LOW ? !on : on;
21  digitalWrite(FAN_PIN, level ? HIGH : LOW);
22}
23
24float readPpm() {
25  long sum = 0;
26  for (int i = 0; i < 20; i++) {
27    sum += analogRead(MQ_PIN);
28    delay(10);
29  }
30  float raw = sum / 20.0;
31  if (raw < 1) raw = 1;
32  float rs = RL_KOHM * (1023.0 - raw) / raw;
33  return PARA * pow(rs / R0_KOHM, -PARB);
34}
35
36void setup() {
37  digitalWrite(FAN_PIN, RELAY_ACTIVE_LOW ? HIGH : LOW);   // önce kapalı seviye, sonra çıkış / off level first, then output
38  pinMode(FAN_PIN, OUTPUT);
39  Serial.begin(9600);
40  while (millis() < WARMUP_MS) delay(100);
41  smoothPpm = readPpm();
42}
43
44void loop() {
45  smoothPpm = smoothPpm * 0.8 + readPpm() * 0.2;          // ani sıçramaları yumuşat / smooth out spikes
46
47  if (!fanOn && smoothPpm > FAN_ON_PPM) {
48    setFan(true);
49  } else if (fanOn && smoothPpm < FAN_OFF_PPM && millis() - fanStart > MIN_RUN_MS) {
50    setFan(false);
51  }
52
53  Serial.print(F("~"));
54  Serial.print(smoothPpm, 0);
55  Serial.print(F(" ppm   fan: "));
56  Serial.println(fanOn ? F("ACIK / ON") : F("KAPALI / OFF"));
57  delay(1000);
58}

The relay module's IN pin goes to D7 and its VCC and GND to the Arduino; the fan connects to its own supply through the relay's COM and NO terminals. If you use a 12 V computer fan, a MOSFET is quieter and lasts longer than a relay; the wiring is the same as in the mini water pump circuit. If you're switching a 230 V device like a bathroom extractor fan, follow the safety rules in the relay module guide to the letter: put the mains side in a closed box and never touch the circuit while it's live. Setting the pin to the off level first and only then making it an output in setup() stops active-LOW relays from clicking on for an instant at power-up.

Logging air quality with an ESP32

Seeing how air quality changes over the day teaches you far more than a single reading: only a log reveals the stale air building up in the bedroom by morning, the spike in the kitchen while cooking or the effect of opening a window. The ESP32 code below takes a reading every minute, fetches the time from the internet (NTP) to put a date and time on each line, and saves the result as CSV to the LittleFS file system in the ESP32's own flash memory. When you open the ESP32's IP address in a browser, you see the latest reading and can download the whole log in one click as a CSV file that opens in Excel. No extra library is needed.

C++ (Arduino IDE)
1#include <WiFi.h>
2#include <WebServer.h>
3#include <LittleFS.h>
4#include <time.h>
5
6const char* WIFI_SSID = "AgAdi";              // Wi-Fi adın / your Wi-Fi name
7const char* WIFI_PASS = "Sifre123";           // Wi-Fi şifren / your Wi-Fi password
8const int MQ_PIN = 34;                        // AO, 10k/20k bölücü üzerinden (ADC1) / through a 10k/20k divider (ADC1)
9const float DIVIDER = 1.5;                    // (10k + 20k) / 20k
10const float RL_KOHM = 1.0;
11const float R0_KOHM = 3.6;                    // ESP32 ile yeniden kalibre et / recalibrate with the ESP32
12const float PARA = 116.6020682;
13const float PARB = 2.769034857;
14const unsigned long LOG_INTERVAL = 60000;     // dakikada bir kayıt / one entry per minute
15
16WebServer server(80);
17unsigned long lastLog = 0;
18float lastPpm = 0;
19
20float readPpm() {
21  uint32_t mv = 0;
22  for (int i = 0; i < 20; i++) {
23    mv += analogReadMilliVolts(MQ_PIN);       // kalibre edilmiş mV / calibrated mV
24    delay(5);
25  }
26  float vout = (mv / 20.0) / 1000.0 * DIVIDER;   // sensör çıkışı (V) / sensor output (V)
27  if (vout < 0.05) vout = 0.05;
28  float rs = RL_KOHM * (5.0 - vout) / vout;
29  return PARA * pow(rs / R0_KOHM, -PARB);
30}
31
32void handleRoot() {
33  String html = "<!DOCTYPE html><html><head><meta charset='utf-8'>"
34                "<meta name='viewport' content='width=device-width,initial-scale=1'></head>"
35                "<body style='font-family:sans-serif'><h2>MQ-135</h2><p>Son ölçüm / last reading: ~";
36  html += String(lastPpm, 0);
37  html += " ppm</p><p><a href='/log.csv'>CSV indir / download</a> | "
38          "<a href='/clear'>Kaydı sil / clear log</a></p></body></html>";
39  server.send(200, "text/html", html);
40}
41
42void handleCsv() {
43  File f = LittleFS.open("/log.csv", "r");
44  if (!f) {
45    server.send(404, "text/plain", "Kayit yok / no log yet");
46    return;
47  }
48  server.streamFile(f, "text/csv");
49  f.close();
50}
51
52void handleClear() {
53  LittleFS.remove("/log.csv");
54  server.sendHeader("Location", "/");
55  server.send(303);
56}
57
58void setup() {
59  Serial.begin(115200);
60  analogSetPinAttenuation(MQ_PIN, ADC_11db);  // yaklaşık 0-3,1 V ölçüm aralığı / about 0-3.1 V range
61  if (!LittleFS.begin(true)) {                // true: ilk açılışta biçimlendir / format on first use
62    Serial.println("LittleFS hatasi / error");
63  }
64  WiFi.mode(WIFI_STA);
65  WiFi.begin(WIFI_SSID, WIFI_PASS);
66  while (WiFi.status() != WL_CONNECTED) {
67    delay(500);
68    Serial.print(".");
69  }
70  Serial.println();
71  Serial.println(WiFi.localIP());
72  configTime(3 * 3600, 0, "pool.ntp.org");    // UTC+3; kendi saat dilimine göre değiştir / change to your time zone
73
74  server.on("/", handleRoot);
75  server.on("/log.csv", handleCsv);
76  server.on("/clear", handleClear);
77  server.begin();
78  lastLog = millis();                         // ilk kayıt 1 dk sonra, sensör ısınırken / first entry after 1 min
79}
80
81void loop() {
82  server.handleClient();
83  if (millis() - lastLog >= LOG_INTERVAL) {
84    lastLog = millis();
85    lastPpm = readPpm();
86    char stamp[20] = "saat-yok";
87    struct tm now;
88    if (getLocalTime(&now, 100)) {
89      strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M", &now);
90    }
91    File f = LittleFS.open("/log.csv", "a");  // dosyanın sonuna ekle / append to the file
92    if (f) {
93      f.printf("%s,%.0f\n", stamp, lastPpm);
94      f.close();
95    }
96    Serial.printf("%s  ~%.0f ppm\n", stamp, lastPpm);
97  }
98}

Because the ESP32's ADC measures differently from the Uno's, it's best to redo the R0 calibration with this circuit; just adapt the readRs() function in the calibration code using the logic of readPpm() here. Connect the sensor to one of the ADC1 pins between GPIO 32 and 39: ADC2 pins can't be used while Wi-Fi is on. Each line is about 22 bytes; with one entry per minute, about 32 KB of data builds up per day, and the 1.5 MB area in the default partition scheme lasts roughly a month and a half. For longer logs you can use an SD card module, and if you want to send the data straight to a spreadsheet, look at the ESP32 with Google Sheets method.

MQ-135 with the Raspberry Pi Pico (MicroPython)

On the Pico you power the module with 5 V from the VBUS pin and connect the AO output to GP26, again through a 10 kΩ / 20 kΩ divider. The Pico's ADC returns a 16-bit value (0–65535) referenced to 3.3 V; the code converts it first to a voltage and then, using the divider ratio, to the sensor output. The formula is the same as on the Arduino. Since VBUS can vary between 4.8 and 5.1 V depending on the USB source, for precise results measure VBUS with a multimeter and correct the 5.0 in the code.

MicroPython
1from machine import ADC
2import time
3
4adc = ADC(26)                          # GP26 = ADC0, AO bölücü üzerinden / AO through a divider
5DIVIDER = 1.5                          # (10k + 20k) / 20k
6RL = 1.0                               # kΩ, karttaki yük direnci / load resistor on the board
7R0 = 3.6                               # kΩ, kendi kalibrasyon sonucun / your calibration result
8PARA, PARB = 116.6020682, 2.769034857
9VCC = 5.0                              # VBUS gerilimi / VBUS voltage
10
11def read_rs():
12    total = 0
13    for _ in range(20):
14        total += adc.read_u16()
15        time.sleep_ms(5)
16    v = total / 20 / 65535 * 3.3 * DIVIDER    # sensör çıkışı (V) / sensor output (V)
17    v = max(v, 0.05)                          # sıfıra bölmeyi önle / avoid dividing by zero
18    return RL * (VCC - v) / v
19
20print("Isiniyor / warming up...")
21time.sleep(180)                        # 3 dk ısınma / 3 min warm-up
22while True:
23    ratio = read_rs() / R0
24    ppm = PARA * ratio ** -PARB        # tahmini CO2 eşdeğeri / estimated CO2 equivalent
25    print("Rs/R0 = %.2f   ~%d ppm" % (ratio, ppm))
26    time.sleep(2)

MQ-135 vs CCS811 vs NDIR CO2 sensors

There are three classes of air quality sensor, and each one answers a different question. The MQ-135 is cheap, analog and simple; it shows general changes in the air. The CCS811 is also a metal-oxide sensor, but it has its own microcontroller inside, manages the heater itself and outputs TVOC (total volatile organic compounds) and eCO2 (equivalent CO2) directly over I2C; we cover it in the CCS811 air quality sensor guide. The only group that measures real CO2 is NDIR (non-dispersive infrared) sensors: modules like the MH-Z19B or SenseAir S8 measure how much infrared light at 4.26 µm is absorbed by CO2 molecules and are barely affected by other gases.

FeatureMQ-135CCS811NDIR (MH-Z19B, SenseAir S8)
Measuring principleResistance of heated SnO2Metal oxide + built-in microcontrollerInfrared light absorption
Measures real CO2?No, it estimatesNo, calculates eCO2 from VOCsYes
OutputAnalog (plus digital threshold)I2CUART or PWM
Supply5 V, about 150 mA3.3 V, about 30 mA on average5 V, under 60 mA on average
Warm-up24–48 h when new, then a few minutes48 h when new, then 20 minutesAbout 3 minutes
AccuracyLow, for relative changesMedium, for relative VOC trackingAbout ±50 ppm + 3–5% of reading
PriceCheapestMediumMost expensive

In short: for learning, triggering an alarm or fan and watching changes in the air, the MQ-135 is enough. If you want to track paint, glue or cleaning product vapours, the CCS811 gives smoother, more stable data. If you need a reliable number to answer "is it time to open the window?" in a classroom, office or bedroom, you'll have to invest in an NDIR CO2 sensor. Many people use both: the NDIR sensor tracks CO2 while the MQ-135 catches sudden events like smoke or solvent smells in the kitchen.

Project ideas with the MQ-135

  • Room air indicator: An RGB LED that glows green, yellow or red depending on air quality, or a live graph on an OLED display.
  • Smart kitchen extractor: A system that switches the extractor on when smoke and steam rise during cooking and off once the air is clean.
  • Garage and paint workshop warning: A circuit that sounds a buzzer and starts ventilation when solvent vapour rises.
  • Cat litter monitor: An ESP32 project that notifies your phone when ammonia rises and it's time to change the litter.
  • Classroom air board: A graph that logs readings with an ESP32 and compares them with lesson times to show ventilation habits.
  • Home automation: Send the values to Home Assistant over MQTT and switch on an air purifier with a smart plug when the air gets worse.

Common mistakes and how to fix them

SymptomLikely causeFix
Values keep drifting for hoursInitial preheat not finishedRun the sensor non-stop for 24–48 hours, then calibrate
Values far too high or low at power-upNo daily warm-up waitWait at least 3 minutes, ideally 10–20, before measuring
Thousands of ppm even in clean airWrong R0, or calibration done indoorsRepeat the calibration in clean outdoor air
On the ESP32 the values are always maxed outAO reaches 5 V, or the sensor runs on 3.3 VPower the sensor from 5 V and add a 10k/20k divider on AO
The Arduino gets warm or USB disconnectsThe 150 mA heater plus other loads strain the USBUse an external 5 V supply and join the grounds
ppm rises in sunlight or after a showerTemperature and humidity effectCalibrate in similar conditions or apply DHT22 correction
Interpret MQ-135 values not as an absolute measurement but as a change from a baseline. After the sensor has warmed up, take the value you measure at a clean moment as the baseline and make decisions with relative thresholds like "30% above baseline"; this approach is much more robust against calibration errors, sensor ageing and seasonal changes. We cover the moving average and median filters you can use to smooth the readings in filtering sensor data.

As long as you know its limits, the MQ-135 is an excellent starter sensor for learning about air quality and automating ventilation: power it from 5 V, give it 24–48 hours of preheat, find R0 in clean air, read the results relatively and move to an NDIR sensor when you need real CO2. In the BlueGrays app you'll find the Gas Sensor, Flame and Gas Sensor Alarm and ESP32 MQ-2 Gas Detector projects, together with DHT11 and DHT22 temperature–humidity and ESP32 temperature logger projects, with step-by-step circuit diagrams and code.

Frequently asked questions

Can the MQ135 measure CO2?

The MQ-135 doesn't truly measure CO2; it estimates it. The sensor responds only weakly to CO2; its real response is to gases such as alcohol, ammonia and benzene and to the volatile compounds people give off. The CO2 ppm value that libraries show is calculated from this general pollution; for real CO2 you need an NDIR sensor such as the MH-Z19B.

How long does the MQ135 take to warm up?

A new MQ-135 needs an initial preheat of 24–48 hours, and this must be completed before calibration. After that, the sensor becomes usable within about 2–3 minutes each time it's switched on; for the most stable values, waiting 10–20 minutes is better.

How do you find the R0 value of the MQ135?

Take a sensor that has completed its preheat into clean outdoor air, calculate its resistance with Rs = RL × (1023 − raw) / raw and, assuming outdoor CO2 is about 420 ppm, compute R0 = Rs × (420 / 116.6)^(1 / 2.769). Average several minutes of readings. The R0 you find belongs only to that sensor and that load resistor.

What is the difference between the MQ135 and the MQ2?

Both are heated metal-oxide sensors, but they're tuned to different gases. The MQ-2 suits LPG, propane, methane, hydrogen and smoke, so it's the one for gas leak alarms. The MQ-135 is sensitive to air pollutants such as ammonia, benzene, alcohol, NOx and smoke, and is used to monitor air quality.

Can the MQ135 be used with an ESP32?

Yes, but watch two things. The sensor's heater needs 5 V, so power the module from the ESP32's VIN pin. The AO output can also reach 5 V, so bring it down to 3.3 V with a 10 kΩ and 20 kΩ voltage divider and connect it to an ADC1 pin between GPIO 32 and 39.

Which gases does the MQ135 detect?

The MQ-135 is most sensitive to ammonia, sulphur compounds and aromatic vapours like benzene. It also responds clearly to alcohol, smoke and nitrogen oxides, and weakly to CO2 and carbon monoxide. It can't tell these gases apart; it only shows the overall change in the air.

See all projects
Temperature and Humidity with DHT11
#55Intermediate

Temperature and Humidity with DHT11

How warm is your room, and is the air humid or dry? The DHT11 sensor measures temperature and humidity, and the Arduino writes the values on a two-line LCD screen, refreshing every couple of seconds.

4.7(47)
73
Using a Gas Sensor
#51Beginner
Premium

Using a Gas Sensor

A little guard that notices a gas leak in the kitchen before you do. The gas sensor keeps sniffing the air; while everything is fine the green LED stays on, and when the gas in the air passes a certai

4.6(37)
50
Flame and Gas Sensor Alarm
#52Advanced
Premium

Flame and Gas Sensor Alarm

A little guard that warns you right away when gas leaks or a flame appears somewhere. The gas sensor watches the air for gas and the flame sensor watches for the light of a fire; while everything is f

4.8(52)
67
ESP32 MQ-2 Gas/Smoke Detector Alarm
#122Intermediate

ESP32 MQ-2 Gas/Smoke Detector Alarm

In this project, combustible gas (LPG, methane, propane) and smoke detection is performed using an MQ-2 gas sensor module, and a siren-like alarm is played through a passive buzzer upon detection. The sensor's digital threshold output (DO) is connected to GPIO 4 on the ESP32; when gas or smoke exceeds the threshold, DO goes LOW and the alarm is triggered. Sensitivity is adjusted via the module's potentiometer. The passive buzzer is driven using tone() to produce a two-frequency siren effect.

4.7(28)
43
ESP32 DHT22 Temperature and Humidity (with LCD Display)
#117Intermediate

ESP32 DHT22 Temperature and Humidity (with LCD Display)

This project measures ambient temperature and humidity using a DHT22 sensor and displays the data in real time on a 16x2 I2C LCD. Data is read from the sensor using the one-wire protocol via the Adafruit DHT library. This project serves as the foundation for IoT weather stations, smart home automation, or environmental monitoring systems in greenhouses. Communication with the DHT22 is established via the ESP32's GPIO 4 pin.

4.8(38)
56
ESP32 Temperature Data Logger (DHT22 + DS1307 RTC + microSD + OLED)
#144Advanced
Premium

ESP32 Temperature Data Logger (DHT22 + DS1307 RTC + microSD + OLED)

Build a timestamped temperature logger with ESP32! Read DHT22 temperature/humidity every minute, get real-world time from DS1307 RTC, and log to microSD card as CSV. The OLED displays live temperature (large font), humidity percentage, timestamp, and log counter. The /temp_log.csv file on the SD card opens in Excel, Google Sheets, or Python pandas for analysis — perfect for greenhouse monitoring, home/room temperature tracking, laboratory data collection, food storage conditions, or beehiv

4.7(7)
12
ESP32 I2C LCD Display (16x2)
#119Intermediate
Premium

ESP32 I2C LCD Display (16x2)

In this project, we display text by connecting a 16x2 character LCD screen to the ESP32 via the I2C protocol. Thanks to I2C, we can control the screen using just two data pins (SDA and SCL), eliminating the waste of 6+ pins associated with a classic parallel connection. The code prints a fixed message on the first line and a counter that increments every second on the second line. Additionally, we run an I2C scanner at the start to print the display’s address to the Serial Monitor (typically 0x2

4.9(23)
40
Lighting 220V Bulb with Push Button
#19Advanced
Premium

Lighting 220V Bulb with Push Button

As long as you hold the button, a real 220-volt bulb stays lit, and it goes out the moment you let go. The Arduino's tiny signal cannot light the bulb directly, so a relay, an electrically controlled

4.4(30)
38