Environmental Technology

Read Modbus TCP Data in Python with pymodbus 3: A Tested Guide

Read holding registers over Modbus TCP with current pymodbus, decode 32-bit floats, handle errors, and fix old tutorial code that no longer runs.

To read a Modbus TCP device from Python, install pymodbus, connect a ModbusTcpClient, call read_holding_registers(address, count=…, device_id=…) and decode the registers with convert_from_registers. Much of the example code online was written for older versions and fails on current ones, so everything in this guide was tested with pymodbus 3.16.1 and Python 3.13 on 25 October 2026.

During my internship in the defense division of L&T Precision Engineering, I worked on Modbus TCP/IP device communication, a data-ingestion and alerting pipeline, and real-time dashboards for live sensor streams. The protocol is just as common in environmental monitoring: flow meters, analysers and controllers often expose their readings over Modbus, so it is usually the first step in getting sensor data into software.

Modbus in five minutes

Modbus is a request–response protocol. A client (called the master in older documents) sends a request; a server (the slave) answers. Over TCP, a server usually listens on port 502, and each request carries a unit identifier — pymodbus calls it device_id — which matters mainly when one gateway sits in front of several serial devices.

A server exposes four tables:

Table Size of each entry Access Read with function code
Coils 1 bit Read and write 1
Discrete inputs 1 bit Read only 2
Input registers 16 bits Read only 4
Holding registers 16 bits Read and write 3

Measurements usually live in holding or input registers. A register holds 16 bits, so anything larger — a 32-bit float, for example — spans two registers. One read request can return up to 125 registers.

Register addresses: 40001 or 0?

Device manuals often list holding registers as 40001, 40002 and so on. That is a documentation convention: the leading 4 names the table, and the count starts at 1. On the wire, the first holding register is address 0. So if a manual says 40001, request address 0; if it says 40010, request address 9.

Some manuals already give 0-based addresses, and a few are simply wrong. The reliable check is to read a register whose value you already know — one shown on the device’s display, for example.

A device to test against

You don’t need hardware to follow along. This script runs a Modbus TCP server on your machine, with a flow rate of 42.75 stored as a 32-bit float in registers 0–1 and a status code of 3 in register 2. It listens on port 5020, because binding to ports below 1024 needs extra privileges on Linux.

Python
from pymodbus.client import ModbusTcpClient
from pymodbus.datastore import ModbusDeviceContext, ModbusSequentialDataBlock, ModbusServerContext
from pymodbus.server import StartTcpServer

flow = ModbusTcpClient.convert_to_registers(42.75, ModbusTcpClient.DATATYPE.FLOAT32)  # [high word, low word]
# Data blocks number registers from 1 internally; the client's address 0 reads the block's first value.
device = ModbusDeviceContext(hr=ModbusSequentialDataBlock(1, flow + [3] + [0] * 97))
StartTcpServer(context=ModbusServerContext(devices={1: device}, single=False), address=("127.0.0.1", 5020))

pymodbus 3.16 prints a deprecation warning for ModbusServerContext: the server-side API changes in pymodbus 4. The client code below is what you will use against real devices.

Read holding registers

Python
from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException

client = ModbusTcpClient("127.0.0.1", port=5020, timeout=2, retries=1)
if not client.connect():
    raise SystemExit("could not connect to the device")
try:
    rr = client.read_holding_registers(0, count=3, device_id=1)
    if rr.isError():                                  # the device answered with a Modbus exception
        raise SystemExit(f"device returned an error: {rr}")
    flow = client.convert_from_registers(rr.registers[0:2], client.DATATYPE.FLOAT32)
    print("raw registers:", rr.registers)
    print(f"flow rate: {flow:.2f}  status: {rr.registers[2]}")
except ModbusException as exc:                        # timeouts and connection problems
    raise SystemExit(f"Modbus error: {exc}")
finally:
    client.close()
raw registers: [16939, 0, 3]
flow rate: 42.75  status: 3

Decode 32-bit values, and check the word order

A 32-bit value spans two registers, and devices disagree about which of the two holds the high word. Read them in the wrong order and you get a valid number, not an error. The value 1234.567 is stored as the registers [17562, 21029]:

Python
regs = [17562, 21029]
client.convert_from_registers(regs, client.DATATYPE.FLOAT32)                      # 1234.5670166015625
client.convert_from_registers(regs, client.DATATYPE.FLOAT32, word_order="little")  # 177455136768.0

Nothing fails; the second reading is simply wrong. The manual should state the word order (it may call it “byte swap” or “word swap”), and as with addresses, the reliable check is a register whose value you know. convert_from_registers also decodes INT32, UINT32, FLOAT64 and strings, with the same word_order argument.

Handle the three kinds of failure

What happened How it shows up in pymodbus 3.16
The device is unreachable client.connect() returns False
The request timed out, or the connection dropped a ModbusException is raised
The device rejected the request rr.isError() is True. Reading register 500 on the test device returned ExceptionResponse(dev_id=1, function_code=131, exception_code=2): function code 131 is 3 + 128, the error reply to “read holding registers”, and exception code 2 means “illegal data address”

The other exception codes you will meet most often are 1 (illegal function: the device doesn’t support that request), 3 (illegal data value) and 4 (server device failure).

Upgrading code from older tutorials

Each of these old forms fails on pymodbus 3.16.1. The versions come from the pymodbus changelog.

Old code What happens now Current code
from pymodbus.client.sync import ModbusTcpClient ModuleNotFoundError — the client package was reorganised in 3.0 from pymodbus.client import ModbusTcpClient
read_holding_registers(0, 2) TypeError: … takes 2 positional arguments but 3 were given — keyword-only parameters since 3.8.0 read_holding_registers(0, count=2)
read_holding_registers(0, count=2, unit=1) or slave=1 TypeError: … got an unexpected keyword argument — slave was renamed device_id in 3.10.0 read_holding_registers(0, count=2, device_id=1)
from pymodbus.payload import BinaryPayloadDecoder ModuleNotFoundError — deprecated in 3.8.0, removed in 3.10.0 client.convert_from_registers(registers, client.DATATYPE.FLOAT32)

Because the API keeps moving, pin the version in your project (for example pymodbus==3.16.1 in requirements.txt) and upgrade deliberately.

Polling a device reliably

A one-off read is easy; reading every few seconds for months is where problems appear. This loop reuses one connection, takes the timestamp when a reading arrives, and backs off when the device goes away:

Python
import logging
import time

from pymodbus.client import ModbusTcpClient
from pymodbus.exceptions import ModbusException

log = logging.getLogger("poller")


def poll(host, port, *, interval=5.0, max_backoff=60.0):
    """Yield (timestamp, registers) for each successful read, forever."""
    client = ModbusTcpClient(host, port=port, timeout=2, retries=1)
    backoff = interval
    while True:
        try:
            if not client.connected and not client.connect():
                raise ConnectionError(f"cannot connect to {host}:{port}")
            rr = client.read_holding_registers(0, count=3, device_id=1)
            if rr.isError():
                log.warning("device rejected the request: %s", rr)
            else:
                yield time.time(), rr.registers  # timestamp taken when the reading arrived
                backoff = interval
            time.sleep(interval)
        except (ModbusException, ConnectionError, OSError) as exc:
            log.warning("read failed (%s); retrying in %.1f s", exc, backoff)
            client.close()
            time.sleep(backoff)
            backoff = min(backoff * 2, max_backoff)

To test it, I stopped the test device a few seconds into a run and restarted it a few seconds later. The loop logged the dropped connection, waited 0.5, 1 and then 2 seconds between attempts, and resumed reading as soon as the device was back.

A few habits make the data trustworthy later:

  • Poll no faster than the device updates. Many instruments refresh their registers once a second or slower; polling faster just adds load.
  • Store the raw registers next to the decoded value. If you later discover a wrong word order or scale factor, you can recompute history instead of losing it.
  • Record gaps. A missing reading is information. Log when readings stop, and alert on it, rather than silently skipping.
  • Use one connection per device. Some devices accept only a few TCP connections at a time.

Checklist

  1. Pin and state the pymodbus version you use.
  2. Confirm the register address convention: the wire is 0-based.
  3. Confirm the word order with a value you know.
  4. Handle unreachable devices, timeouts and exception responses separately.
  5. Reconnect with back-off, and record gaps.
  6. Store raw registers as well as decoded values.

References and further reading

  1. Client, pymodbus documentation
  2. Changelog, pymodbus documentation
  3. Modbus specifications, Modbus Organization

Written by

Thanush BD

Software engineer in Bengaluru working where software meets environmental engineering. At Ecologic Engineering I build audit, compliance, monitoring and water-treatment software — mostly backend: data models, APIs, pipelines and scheduled jobs.

  • Software Engineering

    How to Approach an Unfamiliar Coding Problem

    A repeatable process for problems you have never seen: restate the task, pin down constraints, work examples by hand, start simple, then improve and test.

    7 min read

    • problem-solving
    • algorithms
    • testing
    • python