esp32 beginner 30 min

ESP32: location and time from a NEO-M8N GPS module

Wire a NEO-M8N GPS module to an ESP32 over serial, parse NMEA for position and UTC time, and understand why your first fix takes minutes.

Code available for: ESP32 ArduinoArduino CMicroPython
Published Sep 22, 2026

I put a NEO-M8N on my windowsill, powered it up, and stared at an empty NMEA stream for five minutes wondering if I had wired it wrong. I had not. The module was fine, my wiring was fine, and the chip was doing exactly what GPS modules do: downloading the almanac from every satellite in view, one 50 bits-per-second signal at a time. A cold fix genuinely takes minutes (e.g. 27 seconds is the spec-sheet best case for a cold start; indoors it can be never).

The trap is expecting GPS to behave like an I2C sensor. It does not. There is no “read the register” moment. The module blasts NMEA sentences over serial continuously, and your job is to wait for one that contains a valid fix. Indoors, that may never come, and the module is not broken: it just cannot see the sky.

What you need

Needed

  • ESP32 dev board (e.g. ESP32-DevKitC, about $8).
  • NEO-M8N GPS module with ceramic patch antenna (the u-blox M8 engine on a blue breakout board, about $15). The NEO-6M is the older and cheaper variant and also works with this wiring; the M8N adds GLONASS and Galileo, which means faster fixes and more satellites.
  • 4 jumper wires, female-female (GPS breakouts usually have male pin headers pre-soldered).

Nice to have

  • Active external antenna (u.FL connector, about $8) if you plan to mount the ESP32 somewhere the patch antenna cannot see the sky.
  • USB-serial adapter (CP2102/FTDI), to inspect raw NMEA sentences from a laptop without the ESP32 in the loop.
  • Soldering iron + solder, if the module ships with a bare header.
  • Soldering iron stand, for safe parking of the hot iron.
  • Helping hands, to hold the header straight while soldering.
  • Anti-static wristband, for bare-module handling.
  • Magnifying goggles, for reading the tiny u.FL silkscreen labels.
  • Soldering mat, to keep the desk clean.
  • Wire stripper, for clean antenna lead ends.

Wiring

Serial, crossed. GPS TX talks to ESP32 RX:

Wire key: VCC3.3VGNDTXGPIORX
GPS moduleConnect to
VCCESP32 3V3 (5V works on most breakouts but 3V3 is safer)
GNDESP32 GND
TXESP32 GPIO 16 (RX2)
RXESP32 GPIO 17 (TX2)

The ESP32 has three hardware UARTs. UART2 is free on standard boards, which is why we use GPIO 16/17 instead of the USB-connected UART0.

Some cheap NEO-M8N boards are actually NEO-6M chips relabeled. It does not matter for this tutorial: both speak NMEA at 9600 baud and both work with the same code. If you want to know which one you got, the NMEA talker ID and message set differ slightly (e.g. the M8N emits GLM and GAL sentences you will never see from a 6M).

The patch antenna faces up. Ceramic side toward the sky, metal ground plane side down. Running it face down halves your signal.

Install

Arduino IDE >> Sketch >> Include Library >> Manage Libraries >> search “TinyGPSPlus” >> install (Mikal Hart’s TinyGPSPlus, formerly TinyGPS++). It parses NMEA properly, including checksums, and does not block your loop.

The code

ESP32 (Arduino) with TinyGPSPlus

#include <TinyGPSPlus.h>
#include <HardwareSerial.h>

TinyGPSPlus gps;
HardwareSerial GPSSerial(2);   // UART2: GPIO 16 = RX2, GPIO 17 = TX2

void setup() {
  Serial.begin(115200);
  delay(1000);
  GPSSerial.begin(9600, SERIAL_8N1, 16, 17);   // RX=16, TX=17
  Serial.println("Waiting for GPS fix... (go outside or open a window)");
}

void loop() {
  // Feed every byte from GPS into the parser
  while (GPSSerial.available() > 0) {
    gps.encode(GPSSerial.read());
  }

  // Print once per second when we have a fix
  static unsigned long lastPrint = 0;
  if (millis() - lastPrint > 1000) {
    lastPrint = millis();
    if (gps.location.isValid()) {
      Serial.print("Lat: ");
      Serial.print(gps.location.lat(), 6);
      Serial.print("  Lng: ");
      Serial.print(gps.location.lng(), 6);
      Serial.print("  Sats: ");
      Serial.print(gps.satellites.value());
      Serial.print("  HDOP: ");
      Serial.println(gps.hdop.value() / 100.0, 2);
    } else {
      Serial.print("No fix yet. Sats heard: ");
      Serial.println(gps.satellites.isValid() ? gps.satellites.value() : 0);
    }
    if (gps.time.isValid()) {
      char buf[32];
      snprintf(buf, sizeof(buf), "%02d:%02d:%02d UTC",
               gps.time.hour(), gps.time.minute(), gps.time.second());
      Serial.print("Time: ");
      Serial.println(buf);
    }
  }
}

Reading UTC time and date (the other thing GPS gives you)

GPS time comes off atomic clocks. It is the most accurate clock you can own for free, and it needs no internet:

void printGpsTime() {
  if (gps.date.isValid() && gps.time.isValid()) {
    char buf[40];
    snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d UTC",
             gps.date.year(), gps.date.month(), gps.date.day(),
             gps.time.hour(), gps.time.minute(), gps.time.second());
    Serial.println(buf);
    // Set the system clock from GPS:
    // struct tm t = {0};
    // t.tm_year = gps.date.year() - 1900;
    // t.tm_mon  = gps.date.month() - 1;
    // t.tm_mday = gps.date.day();
    // t.tm_hour = gps.time.hour();
    // t.tm_min  = gps.time.minute();
    // t.tm_sec  = gps.time.second();
    // time_t utc = mktime(&t) - timezoneOffsetSeconds;
    // timeval now = { .tv_sec = utc };
    // settimeofday(&now, nullptr);
  }
}

MicroPython

from machine import UART, Pin
import time

gps_uart = UART(2, baudrate=9600, tx=17, rx=16)

def parse_gga(line):
    # $GPGGA,time,lat,N,lon,E,fix,sats,hdop,alt,...
    parts = line.split(',')
    if len(parts) < 7 or parts[6] == '0':
        return None
    def deg(val, hemi):
        # NMEA lat ddmm.mmmm -> decimal degrees
        d = float(val[:2] if hemi in ('N','S') else val[:3])
        m = float(val[2:] if hemi in ('N','S') else val[3:])
        dec = d + m / 60.0
        return -dec if hemi in ('S','W') else dec
    lat = deg(parts[2], parts[3])
    lon = deg(parts[4], parts[5])
    sats = int(parts[7])
    t = parts[1]
    utc = f"{t[0:2]}:{t[2:4]}:{t[4:6]}"
    return {"lat": lat, "lon": lon, "sats": sats, "utc": utc}

while True:
    if gps_uart.any():
        raw = gps_uart.readline()
        try:
            line = raw.decode('ascii').strip()
        except UnicodeDecodeError:
            line = ''
        if line.startswith('$GPGGA') or line.startswith('$GNGGA'):
            fix = parse_gga(line)
            if fix:
                print(f"Lat {fix['lat']:.6f}  Lon {fix['lon']:.6f}  "
                      f"Sats {fix['sats']}  UTC {fix['utc']}")
    time.sleep_ms(200)

First fix expectations

Take the board outside or put it on a windowsill with sky view. The first fix after power-on from cold takes 1 to 5 minutes (cold start: the module has no almanac and no ephemeris). After that, warm starts take seconds. If the module has a backup battery or stays powered, the next boot is even faster because the ephemeris data is still valid.

If you have never gotten a fix, check the antenna orientation and move outside. Fix problems are 90% antenna placement, 10% everything else.

What you learned

  • GPS modules do not work like I2C sensors. They stream NMEA over serial and you parse it; the fix arrives when the sky math is done, not when you ask for it.
  • UART2 (GPIO 16/17) is the right serial port for GPS on the ESP32; UART0 is wired to the USB chip.
  • TinyGPSPlus handles checksummed NMEA parsing without blocking your loop (e.g. feed bytes in loop(), check validity whenever you want).
  • GPS time is UTC, atomic-clock accurate, and available with no internet connection. It is the best time source you can own for free.
  • A cold fix takes minutes. Put the antenna where it can see the sky.

When something breaks

  • No NMEA data at all. TX and RX are swapped. GPS TX goes to ESP32 RX (GPIO 16). Swap them and watch data appear. If still nothing, check that the module’s power LED is lit and the baud rate is 9600.
  • NMEA data but no fix for 10+ minutes. You are indoors under a metal roof, or the antenna is face down. Move near a window or go outside. Some buildings (concrete plus rebar) block GPS entirely.
  • Fix drops every few minutes. Antenna marginal. Add the external active antenna via the u.FL connector, or move the patch antenna away from the ESP32 (the Wi-Fi radio does not help reception).
  • Time is exactly 8 hours off (or your timezone). GPS time is UTC. Your local time zone offset is your job, not the GPS’s (e.g. Mountain time is UTC minus 7 during daylight saving, minus 6 in winter).
  • Garbage characters instead of NMEA. Baud rate mismatch. Most modules default to 9600; some clones run 115200 or 38400. Try GPSSerial.begin(115200, ...) if 9600 gives you noise.

What to build next

  • The ntfy notifications tutorial sends a phone alert when a device leaves a geofence (e.g. publish the GPS coordinates over MQTT and let a home server decide if the fence was crossed).
  • The MQTT publish-subscribe tutorial streams the coordinates to your dashboard once per minute.
  • The I2S microphone tutorial combines with GPS for a trail recorder: position, time, and audio in one log.
  • The deep sleep tutorial makes a GPS tracker that sleeps between fixes (e.g. wake, fix, publish, sleep for 10 minutes; weeks of battery).