The Direct Answer: Wiring a GPS Module to Raspberry Pi

If you need to add location tracking to your embedded project, connecting a GPS module to a Raspberry Pi via the hardware UART is the most reliable method. For this guide, we are targeting the Raspberry Pi 4 Model B paired with the Adafruit Ultimate GPS Breakout (MTK3339 chipset). We use this specific module because it natively operates at 3.3V logic, eliminating the need for logic level shifters that cheaper 5V modules require.

The direct answer for communication: you will read NMEA 0183 sentences from /dev/serial0 at a default baud rate of 9600. The MTK3339 updates at 1Hz by default, outputting standard GGA and RMC sentences containing latitude, longitude, speed, and UTC time.

Difficulty Rating: Intermediate (Requires OS-level UART configuration and basic Python serial parsing).
Estimated Time: 45 minutes (hardware setup + software config + first satellite fix).

Required Parts List

  • Board: Raspberry Pi 4 Model B (2GB, 4GB, or 8GB variant; running Raspberry Pi OS Bookworm or Bullseye 64-bit).
  • GPS Module: Adafruit Ultimate GPS Breakout - 66 channel w/10 Hz updates (Product ID: 746, MTK3339 chipset). Typical street price: $40-$45.
  • Antenna: Active GPS Patch Antenna with SMA connector (3m cable recommended for window placement).
  • Wiring: 4x Female-to-Female jumper wires (silicone, 26 AWG).

Pin Mapping Table

Wire the GPS breakout to the Pi's 40-pin GPIO header. Remember the golden rule of serial communication: TX always connects to RX.

GPS Breakout Pin Raspberry Pi 4 GPIO Pin Physical Pin # Function
VIN 3.3V Power 1 Module Power (3.3V-5V tolerant)
GND Ground 6 Common Ground
TX GPIO 15 (RXD) 10 GPS sends data to Pi
RX GPIO 14 (TXD) 8 Pi sends config to GPS (Optional)
Hardware Warning: If you are using a generic u-blox NEO-6M module from a marketplace like Amazon or AliExpress, check the logic levels. Many cheap NEO-6M boards have a 5V TX output. Feeding 5V into the Pi 4's 3.3V GPIO 15 (RXD) will permanently damage the Pi's SoC. Always use a bidirectional logic level shifter or a voltage divider (2kΩ/3.3kΩ) on the TX line if using a 5V module.

Step-by-Step Hardware Setup & OS Configuration

The Raspberry Pi 4 routes the primary mini UART (/dev/ttyS0) to the GPIO header by default, aliased as /dev/serial0. However, the OS uses this serial port for the Linux console by default. You must disable the console login while keeping the hardware UART enabled.

  1. Wire the module according to the pin mapping table above. Connect the active SMA antenna to the GPS breakout and route the cable near a window or outdoors.
  2. Boot the Pi and open a terminal (or SSH in).
  3. Run the configuration tool: Type sudo raspi-config and press Enter.
  4. Navigate to: Interface Options -> Serial Port.
  5. Answer the prompts:
    • "Would you like a login shell to be accessible over serial?" -> Select No.
    • "Would you like the serial port hardware to be enabled?" -> Select Yes.
  6. Exit and reboot: Select Finish and reboot the Pi when prompted.
  7. Verify the port: After reboot, run ls -l /dev/serial*. You should see /dev/serial0 pointing to ttyS0.

For deeper technical details on how the Pi 4 handles UART routing versus the newer Pi 5 (which uses /dev/ttyAMA0 for the GPIO header), refer to the official Raspberry Pi UART documentation.

Complete Python Code for NMEA Parsing

While you can use the OS-level gpsd daemon, it introduces complex socket configurations and background service debugging that often trips up beginners. For standalone embedded projects, reading the serial stream directly and parsing NMEA sentences with Python is cleaner and more portable.

You will need two libraries. Install them via pip:

pip3 install pyserial pynmea2

Below is the complete, compilable Python script. It includes robust error handling for serial disconnects and malformed NMEA data, which is common during the first few seconds of a GPS boot sequence.

import serial
import pynmea2
import sys
import time

# --- PIN & PORT DEFINITIONS ---
# On Pi 4, /dev/serial0 maps to the GPIO UART (ttyS0)
SERIAL_PORT = '/dev/serial0'
BAUD_RATE = 9600
TIMEOUT = 2  # Seconds to wait for serial data

def parse_gps_stream():
    """Opens serial port and continuously parses NMEA GGA sentences."""
    try:
        ser = serial.Serial(
            port=SERIAL_PORT,
            baudrate=BAUD_RATE,
            timeout=TIMEOUT,
            parity=serial.PARITY_NONE,
            stopbits=serial.STOPBITS_ONE,
            bytesize=serial.EIGHTBITS
        )
        print(f"Successfully opened {SERIAL_PORT} at {BAUD_RATE} baud.")
    except serial.SerialException as e:
        print(f"[FATAL] Failed to open serial port: {e}")
        print("Check if /dev/serial0 exists and if you have dialout permissions.")
        sys.exit(1)

    try:
        while True:
            # Read a line from the serial buffer
            raw_line = ser.readline()
            
            # Decode bytes to string, ignoring malformed characters
            try:
                line = raw_line.decode('ascii', errors='replace').strip()
            except UnicodeDecodeError:
                continue

            # We only care about sentences starting with $
            if not line.startswith('$'):
                continue

            try:
                msg = pynmea2.parse(line)
                
                # GGA sentences contain the actual fix data (Lat/Lon/Alt)
                if isinstance(msg, pynmea2.types.talker.GGA):
                    if msg.gps_qual == 0:
                        print("[STATUS] Waiting for satellite fix...")
                    else:
                        print(f"[FIX] Lat: {msg.latitude:.6f}, Lon: {msg.longitude:.6f} | "
                              f"Alt: {msg.altitude:.1f}m | Satellites: {msg.num_sats}")
                              
            except pynmea2.ParseError as e:
                # The MTK3339 sometimes outputs proprietary sentences or 
                # corrupted bytes on startup. We catch and ignore these.
                print(f"[WARN] Parse error on line: {line} -> {e}")
                continue

    except KeyboardInterrupt:
        print("\n[INFO] GPS tracking stopped by user.")
    finally:
        if 'ser' in locals() and ser.is_open:
            ser.close()
            print("[INFO] Serial port closed.")

if __name__ == '__main__':
    parse_gps_stream()

Debugging: When the Fix Fails or Code Crashes

GPS integration is notorious for failing silently. If your script runs but outputs nothing, or crashes immediately, follow this diagnostic tree.

The First Three Things to Check

  1. Is the serial console actually disabled? Run cat /proc/cmdline | grep console. If you see console=serial0,115200, the OS is hijacking your UART. Re-run raspi-config.
  2. Is the antenna placed correctly? A GPS patch antenna cannot see satellites through a solid roof or low-E glass. It must have a clear line of sight to the sky. For bench testing, hang the antenna out an open window.
  3. Are TX and RX crossed? Verify that the GPS TX pin is wired to the Pi RX pin (GPIO 15). If they are backwards, the script will hang on ser.readline() indefinitely.

Exact Error Strings and Ranked Causes

Error 1: serial.serialutil.SerialException: [Errno 13] Permission denied: '/dev/serial0'

  • Cause A (Most Likely): Your current user is not in the dialout group, which owns the serial devices.
  • Fix: Run sudo usermod -a -G dialout $USER, then log out and log back in.
  • Cause B: You are trying to access /dev/ttyS0 directly instead of the /dev/serial0 alias, and the device tree hasn't mapped it correctly.

Error 2: pynmea2.ParseError: could not parse... checksum mismatch

  • Cause A: Baud rate mismatch. The MTK3339 defaults to 9600. If you previously sent a command to change it to 115200 and the module saved it to EEPROM, your Python script will read garbage.
  • Fix: Change BAUD_RATE = 115200 in the script, or perform a factory reset on the GPS module by sending the $PMTK104*37 command via a serial terminal.
  • Cause B: Electrical noise on the TX line. Ensure your jumper wires are under 10cm long and not routed parallel to high-current DC motor wires.

Error 3: Script runs, but prints [STATUS] Waiting for satellite fix... forever.

  • Cause A: The MTK3339 has a status LED. If it blinks every 15 seconds, it is searching. If it blinks once per second, it has a fix. If it is solid or off, check power.
  • Cause B: Cold start delay. A GPS module with no saved ephemeris data takes 1 to 15 minutes to achieve a first fix (TTFF). Leave it running near a window for at least 10 minutes before assuming it's broken.

Extending and Simplifying the Build

Depending on your project constraints, you may want to scale this setup up for a vehicle tracker or down for a quick proof-of-concept.

How to Simplify (The USB Route)

If you want to skip GPIO wiring and UART configuration entirely, buy a u-blox VK-172 USB GPS Dongle (~$25). It plugs directly into a Pi USB port and mounts as a standard serial device (usually /dev/ttyACM0). You simply change the SERIAL_PORT variable in the Python script to /dev/ttyACM0, and you can completely skip the raspi-config UART steps. This is the best route for dashcam or marine navigation projects where USB ports are plentiful.

How to Extend (Data Logging & I2C Displays)

To turn this into a standalone tracker:

  1. Add an I2C OLED: Wire an SSD1306 128x64 OLED display to the Pi's I2C pins (SDA to GPIO 2, SCL to GPIO 3). Use the adafruit-circuitpython-ssd1306 library to render the msg.latitude and msg.longitude variables directly to the screen, eliminating the need for an HDMI monitor.
  2. CSV Logging: Import Python's csv and datetime modules. Open a file in append mode inside the while loop and write a row containing the UTC timestamp, Lat, Lon, and Altitude. Wrap the file write in a try/except IOError block to prevent the script from crashing if the SD card momentarily hangs.

Frequently Asked Questions

Why is my Raspberry Pi GPS module not getting a fix indoors?

GPS signals operate at extremely low power levels (around -130 dBm) by the time they reach Earth's surface. Standard building materials, especially concrete, metal roofing, and energy-efficient Low-E window coatings, block these L1 band (1575.42 MHz) signals entirely. You must use an active antenna and place it outdoors or directly against a standard glass window. If you require indoor tracking, you need to integrate a dead-reckoning IMU or rely on Wi-Fi/cellular triangulation, not GPS.

Can I use a NEO-6M GPS module with Raspberry Pi safely?

Yes, but only if you verify the logic levels. The u-blox NEO-6M chip itself is 3.3V, but the cheap breakout boards sold online often include a 5V voltage regulator and route the TX pin directly from a 5V logic buffer. If you measure 5V on the TX pin with a multimeter, you must use a logic level shifter or a simple resistor voltage divider to step it down to 3.3V before connecting it to the Pi's GPIO 15. Failing to do so will destroy the Pi's RX pin.

What is the difference between /dev/ttyS0 and /dev/serial0?

On the Raspberry Pi 4, /dev/ttyS0 is the actual mini UART hardware device. However, Raspberry Pi OS creates a symbolic link called /dev/serial0 that always points to the primary UART mapped to the GPIO header, regardless of whether the underlying hardware is the mini UART (ttyS0) or the PL011 UART (ttyAMA0). Always code against /dev/serial0 to ensure your Python script remains portable across different Pi models and OS updates. For more on this mapping, see the Adafruit GPS on Raspberry Pi guide.

How do I log GPS data to a file in the background without a monitor?

You can run the Python script as a systemd service. Create a file at /etc/systemd/system/gps-logger.service, define the ExecStart=/usr/bin/python3 /home/pi/gps_script.py, and set Restart=always. Enable it with sudo systemctl enable gps-logger. This ensures the script starts on boot and automatically restarts if the serial connection drops or the script crashes.