Intermediate

Whether you are building a weather station with a Raspberry Pi, logging data from an Arduino sensor, or communicating with industrial equipment over RS-232, Python gives you a clean, cross-platform API to talk to serial ports. The pyserial library makes serial communication approachable — you open a port, send bytes, read bytes, and close it. No C drivers, no platform-specific system calls.

The good news is that pyserial works on Windows, macOS, and Linux with the same code. You install it once with pip install pyserial, and the same script runs on your laptop and on an embedded Linux board. The library handles port configuration, timeouts, and byte buffers for you.

In this article we will cover everything you need to send and receive data over serial ports in Python. We will start with installation and port discovery, then walk through writing and reading bytes, configuring baud rates and timeouts, handling communication errors, and finish with a real-world data logger project that reads sensor output continuously.

Talking to a Serial Port: Quick Example

Before diving into the details, here is a minimal working example that opens a serial port, sends a command, and reads the response. This is the core pattern you will use in every serial project.

# quick_serial.py
import serial
import time

# Open the port -- adjust the port name for your OS
# Windows: 'COM3', 'COM4', etc.
# Linux/macOS: '/dev/ttyUSB0', '/dev/tty.usbserial-XXXX'
port = serial.Serial(
    port='/dev/ttyUSB0',
    baudrate=9600,
    timeout=1  # Read timeout in seconds
)

time.sleep(2)  # Allow device to reset after connection

port.write(b'HELLO\n')        # Send bytes -- note the b'' prefix
response = port.readline()     # Read until newline or timeout
print('Received:', response.decode('utf-8').strip())

port.close()

Output:

Received: OK

The key insight here is that serial communication is byte-oriented — you always send and receive bytes, not strings. The b'HELLO\n' syntax creates a bytes literal, and decode('utf-8') converts the response bytes back to a string for printing. The timeout=1 argument prevents readline() from blocking forever if the device does not respond.

Want to go deeper? Below we cover port discovery, all the configuration options, error handling, and a complete data logger project that runs indefinitely.

What Is Serial Communication and When Do You Use It?

Serial communication means sending data one bit at a time over a single wire (plus a ground reference). It is one of the oldest and most widely supported communication protocols in electronics, which is exactly why it is still everywhere — Arduino boards, GPS modules, industrial PLCs, barcode scanners, and scientific instruments all use it.

The key parameters that both sides must agree on are baud rate (bits per second), data bits (almost always 8), stop bits (usually 1), and parity (usually None). If these settings do not match between your Python script and the device, you will get garbage data or nothing at all.

ParameterCommon ValuesWhat It Controls
Baud rate9600, 115200, 57600Bits per second transferred
Data bits8 (almost universal)Bits per character
Stop bits1 (default), 2Bits marking end of character
ParityNone, Even, OddError detection bit
Timeout0.5, 1, 2 (seconds)How long to wait for data

The pyserial library exposes all of these as constructor arguments, so you can match whatever your hardware device expects.

Installing pyserial and Finding Your Port

Install pyserial with pip. It has no required dependencies beyond the Python standard library:

# install_pyserial.py
# Run this in your terminal (not Python):
# pip install pyserial

import serial
print('pyserial version:', serial.__version__)

Output:

pyserial version: 3.5

Before you can open a port, you need to know its name. The port name depends on the operating system and the USB-to-serial adapter you are using. The serial.tools.list_ports module lets you enumerate all available ports programmatically instead of guessing:

# list_ports.py
from serial.tools import list_ports

# List all available serial ports
ports = list_ports.comports()

if not ports:
    print('No serial ports found.')
else:
    for port in ports:
        print(f'Port: {port.device}')
        print(f'  Description: {port.description}')
        print(f'  Hardware ID: {port.hwid}')
        print()

Output:

Port: /dev/ttyUSB0
  Description: USB2.0-Serial
  Hardware ID: USB VID:PID=1A86:7523 LOCATION=1-1.2

Port: /dev/ttyACM0
  Description: Arduino Uno
  Hardware ID: USB VID:PID=2341:0043 LOCATION=1-1.3

The description and hwid fields are incredibly useful for identifying which port belongs to which device. The Hardware ID includes the USB Vendor ID (VID) and Product ID (PID), which are unique to each manufacturer. You can use these to auto-detect a specific device in your scripts rather than hardcoding the port name, which changes between computers and reboots.

Opening and Configuring a Port

There are two ways to open a serial port with pyserial: by passing all arguments to the constructor (which opens immediately), or by creating a port object and opening it separately. For most scripts, the constructor approach is cleaner:

# open_port.py
import serial

# Method 1: Open immediately in the constructor
ser = serial.Serial(
    port='/dev/ttyUSB0',
    baudrate=115200,       # Match your device's baud rate
    bytesize=serial.EIGHTBITS,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    timeout=1,             # Read timeout (seconds)
    write_timeout=1        # Write timeout (seconds)
)

print('Port open:', ser.is_open)
print('Port name:', ser.name)
print('Baud rate:', ser.baudrate)

ser.close()

# Method 2: Open separately (useful for port switching)
ser2 = serial.Serial()
ser2.port = '/dev/ttyUSB0'
ser2.baudrate = 9600
ser2.timeout = 0.5
ser2.open()
print('Port 2 open:', ser2.is_open)
ser2.close()

Output:

Port open: True
Port name: /dev/ttyUSB0
Baud rate: 115200
Port 2 open: True

The timeout parameter is one of the most important settings. Without it, read() and readline() will block indefinitely waiting for data that may never come — this hangs your script. With timeout=1, reads return after 1 second even if no data arrived, letting your code handle the absence of data gracefully.

Writing and Reading Data

Serial communication is fundamentally about sending and receiving bytes. pyserial gives you several methods for reading data, each suited to different communication protocols:

# write_read.py
import serial
import time

ser = serial.Serial('/dev/ttyUSB0', baudrate=9600, timeout=1)
time.sleep(2)  # Wait for device to initialize

# --- Writing ---
# write() sends bytes and returns the number of bytes written
num_bytes = ser.write(b'GET_TEMP\n')
print(f'Sent {num_bytes} bytes')

# --- Reading methods ---

# readline() -- reads until '\n' or timeout
line = ser.readline()
print('readline:', line.decode('utf-8').strip())

# read(n) -- reads exactly n bytes (or fewer if timeout)
ser.write(b'STATUS\n')
chunk = ser.read(10)   # Read up to 10 bytes
print('read(10):', chunk)

# read_until() -- reads until a specific terminator
ser.write(b'VERSION\n')
response = ser.read_until(b'\r\n')   # Read until CRLF
print('read_until:', response.decode('utf-8').strip())

# in_waiting -- check how many bytes are waiting in the buffer
ser.write(b'PING\n')
time.sleep(0.1)
print(f'Bytes waiting: {ser.in_waiting}')
buffered = ser.read(ser.in_waiting)  # Read everything available
print('buffered read:', buffered.decode('utf-8').strip())

ser.close()

Output:

Sent 9 bytes
readline: TEMP=23.4
read(10): b'OK\r\n'
read_until: 1.2.3
Bytes waiting: 4
buffered read: PONG

The in_waiting property combined with read(ser.in_waiting) is a common pattern for draining the input buffer — it reads whatever bytes have already arrived without waiting for more. This is useful for fast polling loops where you want to process all available data immediately rather than waiting for a line terminator.

Using a Context Manager

Like file handles, serial ports should always be closed when you are done with them. The cleanest way to ensure this — even if an exception occurs — is to use serial.Serial as a context manager with the with statement:

# context_manager.py
import serial
import time

# The port closes automatically when the block exits, even on exception
with serial.Serial('/dev/ttyUSB0', baudrate=9600, timeout=1) as ser:
    time.sleep(2)
    ser.write(b'GET_STATUS\n')
    response = ser.readline()
    print('Status:', response.decode('utf-8').strip())

# Port is now closed -- no ser.close() needed
print('Port closed automatically')

Output:

Status: READY
Port closed automatically

Using the context manager is the recommended pattern for any script that opens a serial port. It prevents port leaks where the port stays open in the OS even after your Python script exits, which would cause “port already in use” errors the next time you try to connect.

Handling Errors and Timeouts

Real hardware is messy — devices reset unexpectedly, cables get unplugged, and baud rates get misconfigured. Robust serial code must handle these situations without crashing:

# error_handling.py
import serial
import serial.serialutil
import time

def safe_read_line(ser, retries=3):
    """Read a line with retry logic."""
    for attempt in range(retries):
        try:
            line = ser.readline()
            if line:  # Empty bytes b'' means timeout
                return line.decode('utf-8').strip()
            else:
                print(f'Timeout on attempt {attempt + 1}')
        except serial.SerialException as exc:
            print(f'Serial error: {exc}')
            if attempt < retries - 1:
                time.sleep(0.5)
    return None

try:
    with serial.Serial('/dev/ttyUSB0', baudrate=9600, timeout=1) as ser:
        time.sleep(2)
        
        # Flush any stale data in the buffer
        ser.reset_input_buffer()
        ser.reset_output_buffer()
        
        ser.write(b'MEASURE\n')
        result = safe_read_line(ser)
        
        if result is not None:
            print('Measurement:', result)
        else:
            print('Device did not respond after retries')

except serial.SerialException as exc:
    # Port not found, permission denied, etc.
    print(f'Could not open port: {exc}')
except PermissionError:
    print('Permission denied -- try: sudo usermod -a -G dialout $USER')

Output:

Measurement: 23.7C

The empty bytes check (if line:) is critical -- when readline() times out, it returns b'' (empty bytes), not None. Calling .decode() on empty bytes gives an empty string, which is easy to miss. Always check that the read returned actual data before processing it. The reset_input_buffer() call clears any stale bytes that accumulated while your script was doing other things -- a good habit before sending a command that expects a fresh response.

Real-Life Example: Continuous Sensor Data Logger

Here is a practical data logger that reads sensor values continuously from a serial device, handles disconnections gracefully, and saves the readings to a CSV file. This pattern is used in everything from DIY weather stations to industrial monitoring systems.

# sensor_logger.py
import serial
import serial.serialutil
import csv
import time
from datetime import datetime
from serial.tools import list_ports

def find_arduino_port():
    """Find the first Arduino port automatically by USB VID."""
    for port in list_ports.comports():
        # Arduino's USB vendor ID is 0x2341
        if '2341' in port.hwid:
            return port.device
    return None

def parse_sensor_line(raw_line):
    """Parse 'TEMP=23.4,HUM=55.2' format into a dict."""
    result = {}
    try:
        for part in raw_line.split(','):
            key, value = part.split('=')
            result[key.strip()] = float(value.strip())
    except (ValueError, AttributeError):
        pass  # Return empty dict on parse failure
    return result

def run_logger(port_name, output_file='sensor_log.csv', duration=60):
    """Log sensor data for `duration` seconds."""
    fieldnames = ['timestamp', 'TEMP', 'HUM', 'PRESSURE']
    
    with open(output_file, 'w', newline='') as csvfile:
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore')
        writer.writeheader()
        
        start_time = time.time()
        readings = 0
        
        try:
            with serial.Serial(port_name, baudrate=9600, timeout=1) as ser:
                time.sleep(2)  # Device reset delay
                ser.reset_input_buffer()
                print(f'Logging from {port_name} for {duration}s...')
                
                while (time.time() - start_time) < duration:
                    line = ser.readline().decode('utf-8', errors='ignore').strip()
                    
                    if not line:
                        continue  # Timeout -- keep waiting
                    
                    data = parse_sensor_line(line)
                    if not data:
                        print(f'Skipped unparseable line: {line!r}')
                        continue
                    
                    data['timestamp'] = datetime.now().isoformat()
                    writer.writerow(data)
                    csvfile.flush()  # Write to disk immediately
                    readings += 1
                    print(f'[{data["timestamp"]}] {line}')
                    
        except serial.SerialException as exc:
            print(f'Serial error (device disconnected?): {exc}')
        
        print(f'Done. {readings} readings saved to {output_file}')

if __name__ == '__main__':
    port = find_arduino_port() or '/dev/ttyUSB0'
    run_logger(port_name=port, duration=60)

Output:

Logging from /dev/ttyACM0 for 60s...
[2026-05-05T10:00:01.234] TEMP=23.4,HUM=55.2
[2026-05-05T10:00:02.235] TEMP=23.5,HUM=55.1
[2026-05-05T10:00:03.236] TEMP=23.4,HUM=55.3
...
Done. 58 readings saved to sensor_log.csv

This logger auto-detects the Arduino port by USB vendor ID, so it works across different computers without hardcoding the port name. The csvfile.flush() call ensures data is written to disk after every reading -- if the program is interrupted (Ctrl+C, power loss), you won't lose any data. You can extend this by adding a SIGINT handler for graceful shutdown, uploading readings to an MQTT broker, or triggering alerts when values exceed thresholds.

Frequently Asked Questions

What does "could not open port: [Errno 2] No such file or directory" mean?

This error means the port name you specified does not exist on your system. On Linux, USB serial adapters typically appear as /dev/ttyUSB0 or /dev/ttyACM0. On macOS, they appear as /dev/tty.usbserial-XXXX where XXXX is the adapter's serial number. On Windows, they appear as COM3, COM4, etc. Use list_ports.comports() to enumerate available ports and find the correct name rather than guessing.

Why am I getting "Permission denied" on Linux?

On Linux, serial ports belong to the dialout group by default. Your user account needs to be in that group to open serial ports without running as root. Add yourself with sudo usermod -a -G dialout $USER and then log out and back in. Alternatively, run your script with sudo as a temporary workaround, though this is not recommended for long-term use.

Why am I receiving garbage data instead of readable text?

Garbage data almost always means the baud rate does not match between your Python script and the serial device. Both ends must use the exact same baud rate -- if the device sends at 115200 baud but your script opens the port at 9600, you will get nonsensical bytes. Check your device's documentation or datasheet for the correct baud rate. Other causes include wrong parity or stop bit settings, but baud rate mismatch is by far the most common.

Why does readline() hang forever?

By default, readline() blocks until it receives a newline character (\n) or the port is closed. If the device never sends a newline, it blocks forever. Always set a timeout when opening the port -- this makes readline() return after the timeout period even if no newline was received. For devices that use carriage return + newline (\r\n), use read_until(b'\r\n') instead of readline().

Can I read from multiple serial ports simultaneously?

Yes -- use Python's threading module to run a reader loop in a separate thread for each port. Create one serial.Serial object per port and pass each to its own thread. The threading.Event class is useful for coordinating shutdown -- set a stop event and check it in each reader loop. For more advanced use cases, the asyncio module with serial_asyncio (a separate package) provides coroutine-based serial reading.

Conclusion

pyserial gives Python direct access to the serial hardware layer that powers countless embedded systems, scientific instruments, and industrial controllers. In this article we covered installing pyserial and discovering available ports, opening a port with the right baud rate and timeout settings, sending bytes with write() and receiving data with readline() and read(), using context managers for safe port handling, and building a robust data logger with error handling and CSV output.

The real-life sensor logger is a solid foundation -- extend it by adding data visualization with matplotlib, pushing readings to a cloud database, or using the threading module to read from multiple ports simultaneously. The skills transfer directly to any device that speaks serial: GPS modules, temperature sensors, motor controllers, and RFID readers all use the same read/write pattern.

For the full pyserial API reference, see the official pyserial documentation.