Code Library
Machine DataPythonPython 3.8+ · asyncuaMIT licenseIntermediate

Subscribe to OPC UA data changes and log them to CSV (Python)

The OPC UA machine reader already in this library reads a snapshot once. Most real monitoring wants the other half of OPC UA's story: subscribe to a node and get pushed updates the instant the value changes, instead of polling on a timer. This script does that — subscribe, log every change to CSV with a timestamp, unsubscribe when the duration's up. Tested end to end against a local OPC UA test server, watching a variable that changes once a second, before this ever needs real hardware.

Before you run it

  • pip install asyncua
  • An OPC UA server endpoint URL and the NodeId of the variable to watch — browse for it first with the OPC UA machine reader if you don't already know it

The code

GitHub
"""Subscribe to an OPC UA node's data changes and log them to CSV (asyncua).

Usage:  python opcua_subscription_logger.py opc.tcp://192.168.0.20:4840 "ns=2;i=2" out.csv 30
"""

import asyncio
import csv
import sys
from datetime import datetime, timezone

from asyncua import Client

PUBLISHING_INTERVAL_MS = 500


class ChangeLogger:
    """asyncua calls datachange_notification() on the event loop for every update."""

    def __init__(self, writer):
        self.writer = writer

    def datachange_notification(self, node, value, data):
        timestamp = datetime.now(timezone.utc).isoformat()
        print(f"{timestamp}  {node}  {value}")
        self.writer.writerow([timestamp, str(node), value])


async def run(url, node_id, csv_path, seconds):
    async with Client(url=url) as client:
        node = client.get_node(node_id)

        with open(csv_path, "w", newline="") as fh:
            writer = csv.writer(fh)
            writer.writerow(["timestamp", "node", "value"])

            handler = ChangeLogger(writer)
            sub = await client.create_subscription(PUBLISHING_INTERVAL_MS, handler)
            sub_handle = await sub.subscribe_data_change(node)

            print(f"Subscribed to {node_id} - logging for {seconds}s")
            await asyncio.sleep(seconds)

            await sub.unsubscribe(sub_handle)
            await sub.delete()


def main():
    if len(sys.argv) < 4:
        sys.exit(
            "Usage: python opcua_subscription_logger.py "
            "<endpoint-url> <node-id> <csv-path> [seconds]"
        )
    url, node_id, csv_path = sys.argv[1:4]
    seconds = int(sys.argv[4]) if len(sys.argv) > 4 else 30
    asyncio.run(run(url, node_id, csv_path, seconds))


if __name__ == "__main__":
    main()

What you get

$ python opcua_subscription_logger.py opc.tcp://192.168.0.20:4840 "ns=2;i=2" spindle_log.csv 5
Subscribed to ns=2;i=2 - logging for 5s
2026-07-13T14:02:11.264581+00:00  ns=2;i=2  4120.0
2026-07-13T14:02:11.775183+00:00  ns=2;i=2  4130.0
2026-07-13T14:02:12.772859+00:00  ns=2;i=2  4140.0
2026-07-13T14:02:13.765920+00:00  ns=2;i=2  4150.0
2026-07-13T14:02:14.761937+00:00  ns=2;i=2  4160.0

spindle_log.csv

timestamp,node,value
2026-07-13T14:02:11.264581+00:00,ns=2;i=2,4120.0
2026-07-13T14:02:11.775183+00:00,ns=2;i=2,4130.0
2026-07-13T14:02:12.772859+00:00,ns=2;i=2,4140.0
2026-07-13T14:02:13.765920+00:00,ns=2;i=2,4150.0
2026-07-13T14:02:14.761937+00:00,ns=2;i=2,4160.0

How it works

  • create_subscription(interval_ms, handler) sets the publishing interval — how often the server is allowed to batch and send updates — not a polling loop on the client side; the server pushes, this script just waits.
  • datachange_notification(node, value, data) on the handler object is the contract asyncua calls into — implement that one method and the subscription mechanics (acknowledgements, keep-alives) are handled for you.
  • Passing the CSV writer into ChangeLogger.__init__ means every notification writes straight to disk as it arrives — nothing is buffered in memory waiting for the script to exit, so a crash mid-run still leaves a usable partial file.
  • sub.unsubscribe() then sub.delete() is the clean two-step teardown — skipping it still works for a short script that's about to exit anyway, but a long-running collector that creates and drops subscriptions repeatedly will leak them on the server without it.

Gotchas & honest limits

  • Same security caveat as the OPC UA reader: this connects with no security policy, which many production servers reject outright. Add a certificate and a signed/encrypted policy for anything beyond a lab test.
  • The publishing interval is a maximum rate, not a guarantee — a variable that never changes never fires a notification, and one that changes faster than the interval gets coalesced, not queued.
  • ChangeLogger is intentionally synchronous — asyncua also supports async def datachange_notification(...) if the handler itself needs to await something (writing to a database, calling another service); don't block the event loop with slow synchronous work in a busier version of this script.
  • This script watches one node. Watching many means either one subscription with multiple subscribe_data_change() calls (cheaper) or multiple subscriptions (more isolation) — the tradeoff matters once you're past a handful of variables.

Goes deeper

Want this adapted to your shop — or built into a real tool?

Samples are the free 80%. The last 20% is the part I do for a living.

Get in touch
Home
Blog
Tools
Code
Contact
LinkedIn
Request Résumé