Getting a GPS on Raspberry Pi working reliably requires bypassing the default serial console, correctly mapping the 3.3V UART pins, and handling NMEA-0183 sentence parsing. For this build, we are targeting the Raspberry Pi 5 (8GB variant) running Raspberry Pi OS Bookworm, paired with the ubiquitous u-blox NEO-6M module.

The direct answer: Wire the NEO-6M TX pin to Pi GPIO 15 (RXD), disable the serial login shell in raspi-config, enable the PL011 UART via config.txt, and parse the 9600-baud NMEA stream using Python’s pynmea2 library.

Build Profile:
Difficulty: Intermediate (Requires Linux CLI and basic soldering/pin mapping)
Time to Complete: 45 minutes
Target Board: Raspberry Pi 5 (8GB) with RP1 Southbridge
Target OS: Raspberry Pi OS (Bookworm 64-bit)

Hardware BOM and GPS Module Comparison

Before wiring, it is worth verifying your exact GPS module. The NEO-6M is the most common hobbyist board, but if you are building a high-altitude balloon or a precision rover in 2026, you may want to upgrade. Below is a data-dense comparison of the most common breakout boards you will encounter.

Module Variant GNSS Constellations Cold Start Time Typical 2026 Price Logic Level
u-blox NEO-6M GPS (L1 C/A only) ~27 seconds $12 - $15 3.3V (w/ LDO)
u-blox NEO-M9N GPS, GLONASS, Galileo, BeiDou ~24 seconds $45 - $55 3.3V / 1.8V
Quectel L76K GPS, GLONASS, Galileo, QZSS ~26 seconds $18 - $22 3.3V
u-blox MAX-M10S GPS, GLONASS, Galileo, BeiDou ~25 seconds $25 - $30 3.3V / 1.8V

Exact Pin Mapping: Pi 5 to NEO-6M

The Raspberry Pi 5 routes its primary UART through the RP1 southbridge chip. The PL011 UART is exposed on the standard 40-pin header. Because the NEO-6M breakout board includes an onboard 3.3V LDO regulator and logic-level shifting, you can wire it directly to the Pi 5 without a logic level converter.

Pi 5 Physical Pin BCM GPIO NEO-6M Pin Wire Color (Std) Notes
Pin 2 5V Power VCC Red Powers the onboard 3.3V LDO
Pin 6 GND GND Black Common ground reference
Pin 8 GPIO 14 (TXD) RXD Yellow Pi transmits to GPS (optional for read-only)
Pin 10 GPIO 15 (RXD) TXD Green GPS transmits NMEA data to Pi

Step-by-Step Wiring and UART Configuration

By default, the Raspberry Pi routes the system console (login shell) to the serial UART. If you do not disable this, the Pi will flood the GPS module with boot text, and your Python script will be blocked from accessing the port. Follow the official Raspberry Pi UART configuration guidelines to free up the hardware port.

  1. Wire the hardware: Connect VCC to Pin 2 (5V), GND to Pin 6, GPS TXD to Pi Pin 10 (GPIO 15/RXD). You can skip Pi TXD to GPS RXD unless you plan to send UBX configuration commands to the module.
  2. Open terminal: Run sudo raspi-config.
  3. Navigate: Go to Interface OptionsSerial Port.
  4. Disable Console: When asked "Would you like a login shell to be accessible over serial?", select No.
  5. Enable Hardware: When asked "Would you like the serial port hardware to be enabled?", select Yes.
  6. Force PL011 UART: Open the boot config file: sudo nano /boot/firmware/config.txt (Note: on Bookworm, the path is /boot/firmware/, not /boot/). Add the following line at the very bottom:
    enable_uart=1
  7. Reboot: Run sudo reboot. The UART is now mapped cleanly to /dev/ttyAMA0 (also symlinked as /dev/serial0).
Bench Tip: Never test a GPS module for the first time indoors. The ceramic patch antenna requires a direct line of sight to the sky. Furthermore, the antenna relies on the copper pour on the PCB acting as a ground plane. If you desolder the module and mount it remotely, you must provide at least a 70x70mm metal ground plane beneath it, or it will never achieve a satellite lock.

Python NMEA Parsing Code

We will use pyserial to read the raw byte stream and pynmea2 to parse the NMEA-0183 sentences. Install the dependencies first: pip3 install pyserial pynmea2. For deeper protocol details, refer to the pynmea2 GitHub repository.

The code below targets the Pi 5 PL011 UART, includes explicit pin/port definitions, and features robust error handling for serial drops and malformed checksums.

import serial
import pynmea2
import sys
import time

# --- Pin & Port Definitions ---
# Pi 5 PL011 UART mapped to GPIO 14/15 via enable_uart=1
UART_PORT = '/dev/ttyAMA0' 
BAUD_RATE = 9600           # NEO-6M default hardware baud rate
TIMEOUT = 1.5              # Seconds to wait for a serial byte

def parse_gps_stream():
    """Reads and parses NMEA sentences from the UART stream."""
    try:
        ser = serial.Serial(UART_PORT, BAUD_RATE, timeout=TIMEOUT)
        print(f"[INFO] Listening on {UART_PORT} at {BAUD_RATE} baud...")
        
        while True:
            # Read raw bytes, decode to ASCII, ignore non-ASCII garbage
            raw_line = ser.readline().decode('ascii', errors='replace').strip()
            
            # NMEA sentences always start with '$'
            if raw_line.startswith('$'):
                try:
                    msg = pynmea2.parse(raw_line)
                    
                    # GGA contains essential fix data (Lat/Lon/Alt/Satellites)
                    if isinstance(msg, pynmea2.types.talker.GGA):
                        if msg.gps_qual > 0:
                            print(f"[FIX] Quality: {msg.gps_qual} | "
                                  f"Lat: {msg.latitude:0.6f} | "
                                  f"Lon: {msg.longitude:0.6f} | "
                                  f"Sats: {msg.num_sats} | "
                                  f"Alt: {msg.altitude}m")
                        else:
                            print("[SEARCH] Waiting for satellite lock...")
                            
                except pynmea2.ParseError:
                    # Silently ignore sentences with corrupted checksums
                    pass
                    
    except serial.SerialException as e:
        print(f"[HARDWARE ERROR] Failed to open serial port: {e}")
        sys.exit(1)
    except KeyboardInterrupt:
        print("\n[INFO] Stream interrupted by user. Closing port.")
        ser.close()
        sys.exit(0)

if __name__ == "__main__":
    parse_gps_stream()

Debugging: Fixing Permission and Lock Errors

When working with serial devices on Linux, you will inevitably hit permission or hardware lock errors. If your script crashes immediately, check the exact error string against the ranked causes below.

Error 1: "Permission denied"

Exact Error String: serial.serialutil.SerialException: [Errno 13] Permission denied: '/dev/ttyAMA0'

  1. Missing User Group (Most Likely): Your current user is not in the dialout group, which governs serial port access. Fix: Run sudo usermod -a -G dialout $USER, then log out and log back in.
  2. Getty Service Hogging the Port: The system login shell is still attached to the UART. Fix: Run sudo systemctl disable serial-getty@ttyAMA0.service and reboot.
  3. Wrong Device Tree Overlay: Bluetooth is mapped to the primary UART. Fix: Add dtoverlay=disable-bt to /boot/firmware/config.txt.

Error 2: "No Fix" or Script Hangs

Symptom: The script runs, but prints [SEARCH] Waiting for satellite lock... indefinitely, or outputs nothing at all.

  1. Indoor Testing: The NEO-6M cannot see satellites through a standard roof. Fix: Take the Pi outside or place the antenna in a window with a clear view of the southern sky. Expect a cold start to take up to 5 minutes.
  2. Baud Rate Mismatch: If you previously connected the module to an Arduino and changed the baud rate via UBX commands, it will no longer be at 9600. Fix: Use the u-center software on a PC to factory reset the module, or cycle through 4800, 9600, and 115200 in the Python script.
  3. RX/TX Crossed: You wired TX to TX and RX to RX. Fix: Swap the yellow and green jumper wires. TX must always connect to RX.
The First 3 Things to Check When It Fails:
  1. Run ls -l /dev/ttyAMA0 to verify the port exists and your user has rw permissions via the dialout group.
  2. Run cat /dev/ttyAMA0 in the terminal. If you see a scrolling wall of raw NMEA text ($GPGGA, $GPRMC), your hardware wiring and OS config are perfect; the issue is in your Python code.
  3. Check the red PPS/Status LED on the NEO-6M breakout. If it is blinking at 1Hz, the module has a satellite fix. If it is solid or off, it is still searching or lacks power.

Extending and Simplifying the Build

Depending on your end goal, a raw UART Python script might not be the most efficient architecture for a production project. Here is how to adapt the build.

How to Simplify: Switch to I2C

If you are already using the Pi’s UART for a LoRaWAN concentrator or a 3D printer controller (like Klipper), you will run out of hardware UART ports. Simplify the build by switching to an I2C GPS module (such as the Adafruit Ultimate GPS I2C breakout). I2C operates on a shared bus (/dev/i2c-1 on Pi 5 GPIO 2/3), meaning you can daisy-chain the GPS alongside BME280 sensors and OLED displays without triggering serial port lock conflicts.

How to Extend: Implement the gpsd Daemon

If you have multiple Python scripts (e.g., one logging to a database, one updating a dashboard) trying to read /dev/ttyAMA0 simultaneously, the OS will throw a "Resource busy" error. Extend the build by installing gpsd (sudo apt install gpsd gpsd-clients).

The gpsd daemon runs in the background, claims the serial port, and serves the parsed GPS data over a local TCP socket (port 2947). Your Python scripts then use the gps library to query the daemon, allowing unlimited concurrent readers and providing automatic background caching of the last known fix when you drive through a tunnel.