Log serial port data to CSV with Python

Read lines from an Arduino, sensor or lab instrument over a serial port and save each one with a timestamp, ready for Excel or pandas.

The problem

You have a device on a USB or serial port (an Arduino streaming sensor readings, a scale, a multimeter, a GPS module) and you want to record what it sends over hours or days. Watching the Arduino Serial Monitor doesn't save anything, and copy-pasting from a terminal doesn't scale.

Requirements
Python 3.9+, pyserial 3.5
Tested with
Python 3.12, pyserial 3.5 on Windows 11 using pyserial's loopback device (loop://), not yet on physical hardware
Download
serial_logger.py

The script

"""
serial_logger.py - log lines from a serial device to a timestamped CSV.

Usage:
    python serial_logger.py --list                    # show available ports
    python serial_logger.py COM3                      # 9600 baud, log.csv
    python serial_logger.py /dev/ttyUSB0 -b 115200 -o readings.csv
Stop with Ctrl+C.
"""
import argparse
import csv
import sys
from datetime import datetime

import serial
from serial.tools import list_ports


def list_available() -> None:
    ports = list_ports.comports()
    if not ports:
        print("No serial ports found.")
    for p in ports:
        print(f"{p.device:<15} {p.description}")


def log(port: str, baud: int, outfile: str, delimiter: str) -> None:
    with serial.Serial(port, baud, timeout=1) as dev, open(outfile, "a", newline="") as f:
        writer = csv.writer(f)
        print(f"Logging {port} @ {baud} baud to {outfile} (Ctrl+C to stop)")
        count = 0
        while True:
            raw = dev.readline()               # waits up to `timeout` seconds
            if not raw:
                continue                        # nothing arrived; keep waiting
            line = raw.decode("utf-8", errors="replace").strip()
            if not line:
                continue
            stamp = datetime.now().isoformat(timespec="milliseconds")
            fields = line.split(delimiter) if delimiter else [line]
            writer.writerow([stamp, *fields])
            f.flush()                           # don't lose data if unplugged
            count += 1
            print(f"{count:>6}  {stamp}  {line}")


def main() -> None:
    parser = argparse.ArgumentParser(description="Log serial data to CSV.")
    parser.add_argument("port", nargs="?", help="e.g. COM3 or /dev/ttyUSB0")
    parser.add_argument("-b", "--baud", type=int, default=9600)
    parser.add_argument("-o", "--output", default="log.csv")
    parser.add_argument("-d", "--delimiter", default=",",
                        help="split each line into columns on this ('' to keep whole line)")
    parser.add_argument("--list", action="store_true", help="list serial ports and exit")
    args = parser.parse_args()

    if args.list or not args.port:
        list_available()
        return

    try:
        log(args.port, args.baud, args.output, args.delimiter)
    except serial.SerialException as e:
        sys.exit(f"Serial error: {e}")
    except KeyboardInterrupt:
        print("\nStopped.")


if __name__ == "__main__":
    main()

How it works

Finding the port. list_ports.comports() asks the operating system what serial devices are connected. Run --list with the device unplugged, then plugged in, and the new entry is yours (COM3 on Windows, /dev/ttyUSB0 or /dev/ttyACM0 on Linux, /dev/cu.usbmodem… on macOS).

Opening the connection. serial.Serial(port, baud, timeout=1) opens the port. The baud rate must match the device: if your Arduino sketch says Serial.begin(115200), run with -b 115200. A mismatch shows up as garbage characters.

timeout=1 is important. readline() waits for a newline (\n), but never longer than 1 second. Without a timeout, the script would block forever if the device stopped sending, and Ctrl+C might not stop it.

Decoding. Serial ports deliver bytes. .decode("utf-8", errors="replace") turns them into text, replacing any corrupted byte (common right after connecting) with � instead of crashing. .strip() removes the \r\n line ending.

Splitting into columns. If the device sends 23.5,41.2, --delimiter "," splits it so the CSV gets separate columns. Pass -d "" to keep each line as a single column.

f.flush() after every row. This writes each row to disk immediately. If the laptop sleeps or the cable is pulled, you keep everything up to that moment.

Append mode. The CSV opens with "a", so restarting the logger adds to the existing file instead of wiping it.

Running it

pip install pyserial
python serial_logger.py --list
python serial_logger.py COM3 -b 115200 -o readings.csv

Matching Arduino sketch for testing:

void setup() { Serial.begin(115200); }
void loop() {
  Serial.print(analogRead(A0));
  Serial.print(",");
  Serial.println(millis());
  delay(500);
}

Output in readings.csv:

2026-10-04T14:02:11.503,512,1503
2026-10-04T14:02:12.004,515,2004

Variations

Add a header row. Before the while loop, add if f.tell() == 0: writer.writerow(["timestamp", "value", "millis"]). f.tell() == 0 means the file is empty, so the header is only written once.

Send a command first. Many instruments only reply when asked (SCPI devices, for example). Replace the read with a request/response loop:

dev.write(b"MEAS:VOLT?\n")
raw = dev.readline()
time.sleep(1)   # poll once per second (import time)

One file per day. Build the filename from the date, f"log_{datetime.now():%Y-%m-%d}.csv", and reopen it when the date changes.

Live plot. Feed each value into matplotlib.animation.FuncAnimation for a scrolling chart. That will be its own post.

Binary protocols. Some devices send fixed-size binary packets, not text lines. Use dev.read(n) and struct.unpack() instead of readline() and decode().

Gear & resources

Some links above may be affiliate links. If you buy through them I may earn a small commission at no extra cost to you. Details.