PyModbus Quick Start: Read Registers with Python

Scripts & AutomationPyModbusPythonModbus RTUModbus TCP
April 17, 2026|8 min read

PyModbus is a free, open-source Python library (BSD license) that implements the full Modbus protocol stack—TCP, RTU, and ASCII—in both synchronous and asynchronous modes. Install it with pip install pymodbus, create a client with AsyncModbusTcpClient or AsyncModbusSerialClient, and you can read holding registers from a building controller in under ten lines of code. This guide covers the PyModbus 3.x async API, which is the recommended approach for all new scripts.

Why Script Modbus with Python

Many building automation controllers—variable frequency drives, power meters, boiler controllers, and standalone DDC boards—expose their data over Modbus rather than BACnet. While a handheld Modbus scanner works for checking a single register, it becomes impractical when you need to poll dozens of devices or log values over time. Python scripting with PyModbus fills that gap. Common use cases include:

PyModbus handles all the low-level protocol framing, CRC calculation, and response parsing. You write straightforward Python calls like client.read_holding_registers(address=0, count=10, slave=1) and get structured result objects back.

Installing PyModbus

PyModbus requires Python 3.8 or later (Python 3.10+ recommended). Install the latest stable release from PyPI:

pip install pymodbus

For serial (RTU/ASCII) communication, install the serial transport dependency as well:

pip install pymodbus[serial]

Virtual environment recommended: Isolate your Modbus tooling from other Python projects to avoid dependency conflicts:

python -m venv modbus-env
source modbus-env/bin/activate   # Linux/macOS
modbus-env\Scripts\activate      # Windows
pip install pymodbus[serial]

Verify the install by running python -c "import pymodbus; print(pymodbus.__version__)". You should see 3.12.1 or later.

Reading Holding Registers (RTU and TCP Examples)

Holding registers (function code 0x03) are the most commonly used Modbus data type in building automation. They store 16-bit values such as temperatures, setpoints, and equipment status codes. PyModbus 3.x provides both synchronous and asynchronous clients. The async API is recommended for new scripts because it handles timeouts cleanly and scales well when polling multiple devices.

Modbus TCP Example

Use AsyncModbusTcpClient to read registers from a TCP-connected device such as a power meter or a Modbus TCP gateway:

import asyncio
from pymodbus.client import AsyncModbusTcpClient

async def read_tcp():
    async with AsyncModbusTcpClient(
        host="192.168.1.100",
        port=502,
        timeout=3
    ) as client:
        # Read 10 holding registers starting at address 0, slave ID 1
        result = await client.read_holding_registers(
            address=0, count=10, slave=1
        )
        if not result.isError():
            print(f"Registers: {result.registers}")
        else:
            print(f"Error: {result}")

asyncio.run(read_tcp())

The async with context manager connects on entry and disconnects on exit, ensuring the TCP socket is always closed. The slave parameter sets the Modbus unit ID—use 1 for most TCP devices, but check the device documentation if you are going through a serial-to-TCP gateway that addresses multiple RTU devices behind it.

Modbus RTU (Serial) Example

Use AsyncModbusSerialClient to communicate over RS-485 with RTU framing. This is the typical setup when a laptop is connected to a Modbus trunk via a USB-to-RS-485 adapter:

import asyncio
from pymodbus.client import AsyncModbusSerialClient

async def read_rtu():
    client = AsyncModbusSerialClient(
        port="/dev/ttyUSB0",      # COM3 on Windows
        baudrate=9600,
        bytesize=8,
        parity="N",
        stopbits=1,
        timeout=3
    )
    await client.connect()

    # Read 5 holding registers from slave 2, starting at address 100
    result = await client.read_holding_registers(
        address=100, count=5, slave=2
    )
    if not result.isError():
        print(f"Registers: {result.registers}")
        # Convert raw register to engineering units
        temp_raw = result.registers[0]
        temp_f = temp_raw / 10.0
        print(f"Supply Air Temp: {temp_f} F")
    else:
        print(f"Error: {result}")

    client.close()

asyncio.run(read_rtu())

Match your serial parameters exactly. Baudrate, parity, and stop bits must match the device's configuration. Mismatched settings are the number-one cause of communication failures on RTU networks. Check the controller's front panel or configuration tool for its current serial settings.

Reading Input Registers

Input registers (function code 0x04) are read-only registers that typically hold live sensor values like temperatures, pressures, and flow rates. The API call mirrors holding registers:

async def read_inputs():
    async with AsyncModbusTcpClient("192.168.1.100", timeout=3) as client:
        # Read 4 input registers starting at address 0, slave 1
        result = await client.read_input_registers(
            address=0, count=4, slave=1
        )
        if not result.isError():
            labels = [
                "Zone Temp", "Supply Temp",
                "Return Temp", "Outdoor Temp"
            ]
            for label, val in zip(labels, result.registers):
                print(f"{label}: {val / 10.0} F")
        else:
            print(f"Read error: {result}")

Many building controllers use holding registers for both read-only and read-write data, so always consult the device's register map to determine which function code to use. If read_input_registers returns an illegal function exception, try read_holding_registers instead.

Writing Registers

Use write_register() (function code 0x06) for a single register or write_registers() (function code 0x10) for multiple consecutive registers:

async def write_setpoint():
    async with AsyncModbusTcpClient("192.168.1.100", timeout=3) as client:
        # Write a single register: set occupied cooling setpoint to 74.0 F
        # Register value = 740 (74.0 * 10), per device register map
        result = await client.write_register(
            address=40, value=740, slave=1
        )
        if not result.isError():
            print("Setpoint written successfully")

        # Write multiple registers in one request
        # Example: set schedule start hour (reg 50) and end hour (reg 51)
        result = await client.write_registers(
            address=50, values=[6, 18], slave=1
        )
        if not result.isError():
            print("Schedule written: 6:00 AM to 6:00 PM")

Always verify writes by reading back the register immediately after writing. Some controllers reject out-of-range values silently—the write appears to succeed, but the register retains its previous value. A read-after-write confirms the controller accepted the new value.

# Write-then-verify pattern
await client.write_register(address=40, value=740, slave=1)
verify = await client.read_holding_registers(address=40, count=1, slave=1)
if not verify.isError() and verify.registers[0] == 740:
    print("Write verified")
else:
    print("WARNING: Write may not have been accepted")

Batch Reading Multiple Devices

A common field task is polling the same register set from every device on a trunk or subnet. Use asyncio.gather() for TCP devices that can be queried in parallel, or sequential polling with error handling for RTU devices (which share a single serial bus):

TCP: Parallel Reads Across Multiple Devices

import asyncio
from pymodbus.client import AsyncModbusTcpClient

DEVICES = [
    {"host": "192.168.1.101", "name": "AHU-1 Power Meter"},
    {"host": "192.168.1.102", "name": "AHU-2 Power Meter"},
    {"host": "192.168.1.103", "name": "Chiller Meter"},
]

async def poll_device(device):
    try:
        async with AsyncModbusTcpClient(
            device["host"], port=502, timeout=3
        ) as client:
            result = await client.read_holding_registers(
                address=0, count=4, slave=1
            )
            if not result.isError():
                return {
                    "name": device["name"],
                    "registers": result.registers
                }
            return {"name": device["name"], "error": str(result)}
    except Exception as e:
        return {"name": device["name"], "error": str(e)}

async def main():
    results = await asyncio.gather(
        *[poll_device(d) for d in DEVICES]
    )
    for r in results:
        if "error" in r:
            print(f"{r['name']}: ERROR - {r['error']}")
        else:
            kw = r["registers"][0] / 10.0
            print(f"{r['name']}: {kw} kW")

asyncio.run(main())

RTU: Sequential Reads on a Shared Serial Bus

import asyncio
from pymodbus.client import AsyncModbusSerialClient

SLAVE_IDS = [1, 2, 3, 4, 5]  # Five VAV controllers on one trunk

async def poll_rtu_trunk():
    client = AsyncModbusSerialClient(
        port="/dev/ttyUSB0",
        baudrate=9600, parity="N", stopbits=1, timeout=3
    )
    await client.connect()

    for slave_id in SLAVE_IDS:
        result = await client.read_holding_registers(
            address=0, count=6, slave=slave_id
        )
        if not result.isError():
            zone_temp = result.registers[0] / 10.0
            setpoint = result.registers[1] / 10.0
            print(
                f"Slave {slave_id}: "
                f"Zone={zone_temp}F, SP={setpoint}F"
            )
        else:
            print(f"Slave {slave_id}: No response")
        # Brief pause between requests to avoid overwhelming
        # slow controllers on the bus
        await asyncio.sleep(0.1)

    client.close()

asyncio.run(poll_rtu_trunk())

RTU devices share the bus—only one transaction can occur at a time. Never use asyncio.gather() to send parallel requests over a single serial port. Each request must complete (or time out) before the next one starts.

Common PyModbus Mistakes

  1. Not checking result.isError() before accessing result.registers. PyModbus returns a ModbusIOException object instead of raising a Python exception when a device does not respond or returns an error. If you skip the error check and call result.registers on an error object, you get an AttributeError. Always check if not result.isError() before using the result.
  2. Mismatched serial parameters for RTU. Baudrate, parity, and stop bits must match the target device exactly. A mismatch produces CRC errors or complete silence. If you get no response from an RTU device, verify serial settings against the controller's configuration screen before investigating anything else.
  3. Off-by-one register addressing. Modbus documentation traditionally uses 1-based register numbers (e.g., register 40001), but the protocol wire format and PyModbus both use 0-based addressing. If a register map says “holding register 40001,” pass address=0 to PyModbus. When a register map says “register 100,” check whether it means protocol address 99 or 100—the document may or may not have already subtracted the offset.
  4. Requesting too many registers in one read. The Modbus specification limits a single read to 125 holding registers (250 bytes). Requesting more triggers an illegal data value exception. If you need to read a large block, split it into multiple requests of 100 or fewer registers each.
  5. Using the wrong slave ID. Every device on a Modbus network has a unique slave (unit) ID, typically between 1 and 247. Sending a request to the wrong slave ID produces silence (no device responds) or reads data from the wrong controller. Confirm the slave ID through the device's configuration menu or front-panel display before scripting.

Platform Compatibility

ComponentRequirementNotes
Python3.8+3.10 or later recommended for best async support
PyModbus3.12.1 (latest stable)BSD license; actively maintained with frequent releases
Serial supportpip install pymodbus[serial]Installs pyserial for RS-232/RS-485 communication
WindowsFull supportUse COM port names (COM3, COM4) for serial connections
LinuxFull supportSerial ports at /dev/ttyUSB0 or /dev/ttyS0; works on Raspberry Pi
macOSFull supportSerial ports at /dev/tty.usbserial-*; TCP works out of the box

USB-to-RS-485 adapters: For RTU communication, you need a USB-to-RS-485 converter. Common chipsets include FTDI FT232R and CH340. On Windows, install the manufacturer's driver and note the assigned COM port number in Device Manager. On Linux, the adapter typically appears as /dev/ttyUSB0 automatically. Ensure your user account has permission to access the serial port (sudo usermod -aG dialout $USER on most Linux distributions).

Firewall considerations for TCP: Modbus TCP uses port 502 by default. If you cannot connect, verify that no host-based firewall is blocking outbound connections to port 502. Some environments use non-standard ports (e.g., 5020)—check the device or gateway configuration.

Source Attribution

This tutorial draws from the following sources:

PyModbus is maintained by the pymodbus-dev community and licensed under the BSD 3-Clause License. You may use it freely in commercial and proprietary projects without restriction.

PyModbusPythonModbus RTUModbus TCPscripting

Was this article helpful?

Related Articles

Need to do this remotely? SiteConduit provides Layer 2 access that preserves BACnet broadcasts — no BBMD needed for remote sessions. Join the waitlist.

SC

SiteConduit Technical Team

Idea Networks Inc.

SiteConduit builds managed remote access for building automation. Our knowledge base is maintained by BAS professionals with hands-on experience deploying and troubleshooting BACnet, Niagara, Modbus, and Facility Explorer systems.