scada
OPC UA explained for people who only know REST
An honest explainer for software engineers — what OPC UA actually is, what an address space is, what a subscription does, and why it's nothing like REST. Practical, not academic.
Lead
You're a software engineer. You spend your days writing services that talk JSON over HTTP. Today, the controls engineer next to you said "the data is on the OPC UA server, just point your client at it" and you nodded confidently.
This post is the explainer you wish you'd had ten minutes ago. It covers what OPC UA actually is, what makes it different from REST, what an address space is, what a subscription is, why the whole thing has 1,400 pages of spec, and which 50 pages you actually need to read. It is not academic. We're going to skip the protocol-archaeology sections.
By the end you'll be able to (a) connect a client to an OPC UA server, (b) browse the address space, (c) subscribe to value changes, and (d) explain to your team why your shiny REST API isn't a drop-in replacement.
What OPC UA is, in one paragraph
OPC UA (Open Platform Communications Unified Architecture) is the dominant industrial protocol for reading data from PLCs, SCADA systems, and any industrial device made after roughly 2010. It is session-oriented, strongly typed, secured with X.509 certificates, and subscription-based by default. It is not request/response. It is not stateless. It is not JSON. It runs over TCP (binary) or HTTPS (less common in practice). The reference implementation most people use is open62541, an MIT-licensed C library.
Now let's unpack each of those.
How it differs from REST
| Aspect | REST | OPC UA |
|---|---|---|
| Connection | Stateless. Each request is independent. | Session-oriented. You open a session, do many things, close the session. |
| Discovery | Out of band (OpenAPI, docs). | In-band — you browse the server's address space at runtime. |
| Typing | Loose (JSON). | Strong. Every value has a NodeId and a type. |
| Security | TLS + token (Bearer / OAuth). | TLS + X.509 client cert + user token, all negotiated at session start. |
| Read pattern | Polling. GET, repeat. | Subscriptions. Server pushes value changes to client. |
| Bandwidth | Polling overhead per request. | Subscriptions only push deltas. |
| Schema | OpenAPI document. | The server is the schema. You browse it. |
Two of these matter more than the others: the server is the schema, and subscriptions push changes. Get those two right and the rest follows.
The address space
In REST, your endpoints are something like GET /v1/sensors/temperature/{id}. They're written down in OpenAPI. You read the doc, you write your client.
In OPC UA, the server has an address space — a hierarchical tree of nodes — and your client browses it at runtime to discover what's there. The root is Objects (NodeId i=85). Under it you find a vendor-specific tree like:
Objects
└── DeviceSet
└── PLC_01
├── DeviceManual
├── DeviceRevision
├── Manufacturer
└── ProductionLine
├── Speed (variable: Double, units rpm)
├── Temperature (variable: Double, units degC)
└── State (variable: Int32, enum 0=Idle 1=Running 2=Fault)
Each node has a NodeId (the key — globally unique, often ns=2;s=ProductionLine.Speed), a BrowseName (the human-friendly name), a DataType, and a set of references to other nodes. To read the speed, you call Read(NodeId) and you get a Variant containing a Double.
The crucial property: you don't get the address space from a doc. You get it from the server. Your client connects, calls Browse(i=85), walks the tree, and discovers what's available. UaExpert (the free GUI from Unified Automation) is the tool every dev uses to browse a server before writing client code.
This is closer to "GraphQL with introspection" than to REST. The server is self-describing.
Sessions and subscriptions
In REST, you GET /sensors/temperature once a second to poll. That's 60 requests per minute per sensor, each with TLS overhead, each independent. For 1,000 sensors at 1Hz, that's 60,000 requests per minute. Not ideal.
In OPC UA, you do this:
- Open a session with the server (one TLS handshake, one client cert exchange, one user token).
- Create a subscription (one RPC) — a logical channel with a publishing interval (say, 100ms).
- Add monitored items to the subscription — one per NodeId you care about. Each can have its own sampling interval and deadband.
- The server pushes notifications to your client whenever a monitored item's value changes (subject to the deadband and sampling interval).
The total network overhead for monitoring 1,000 tags at ~10Hz on a single subscription is dramatically lower than 1,000 polled REST endpoints. The server batches change notifications. The client stays connected. The TLS overhead happens once, at session start.
This is why OPC UA scales to 50,000+ tags on a single server with low CPU — the protocol is designed for it.
Security: certificates everywhere
OPC UA security is opinionated and not optional in any production setup.
- Server identity — X.509 certificate. The server presents it on session creation.
- Client identity — X.509 certificate. The client presents it on session creation.
- Mutual trust — both client and server cert must be in each other's trust list (or you accept-on-first-use, which is what every dev does in week one and immediately regrets in week three).
- User authentication — separately, on top of cert auth, the client presents a username/password or another X.509 user cert.
- Channel security — Sign+Encrypt with
Basic256Sha256is the modern default. OlderBasic128Rsa15is deprecated.
If you've used mTLS in microservices, this is mTLS plus a user-token layer. Most "OPC UA doesn't connect" debugging in week one is certificate trust, not networking. UaExpert and the Prosys OPC UA Simulation Server (free for dev) are how you sanity-check this without writing code.
A practical Python example
# pip install asyncua
import asyncio
from asyncua import Client
async def main():
url = "opc.tcp://192.168.10.42:4840"
async with Client(url=url) as client:
# Browse the root
objects = client.nodes.objects
async for child in await objects.get_children():
print(await child.read_browse_name())
# Read a single value
speed_node = client.get_node("ns=2;s=ProductionLine.Speed")
value = await speed_node.read_value()
print(f"Speed: {value} rpm")
# Subscribe to changes
class Handler:
async def datachange_notification(self, node, val, data):
print(f"{node} -> {val}")
sub = await client.create_subscription(100, Handler())
await sub.subscribe_data_change(speed_node)
await asyncio.sleep(60)
asyncio.run(main())
That's the working shape. Add cert config, error handling, and reconnect logic for production.
Where the spec gets weird
OPC UA covers more than reading values. It defines:
- Methods — RPC calls on nodes. You can invoke a
Calibrate()method exposed by a sensor, with typed arguments. - Events — server-pushed events, separate from value changes. Alarms and conditions sit on top of this.
- Historical access (HA) — read past values from the server's local archive.
- Companion specifications — domain-specific extensions, e.g. PackML, MTConnect-via-OPC-UA, Auto-ID Devices, Robotics. Each one is a published standard for how a particular device class should expose its address space.
You probably don't need any of this for a first integration. Read values, subscribe to changes, ship. Add the rest when you need it.
Why your REST API can't replace it
Three reasons that come up in every "can we just expose this as REST?" conversation:
- Subscriptions vs. polling. You'd need server-sent events or WebSockets to match OPC UA subscriptions. Doable, but you've now built a custom protocol that nobody else speaks.
- Strong typing and self-description. Your REST API is documented in OpenAPI; an OPC UA server is documented in itself. UaExpert, KEPServerEX, Ignition's OPC UA Module, every PLC programming tool — they all know how to browse an OPC UA server. None of them know how to read your custom OpenAPI doc.
- The plant-floor toolchain. Every modern PLC, every SCADA, every industrial gateway speaks OPC UA. None of them speak your custom REST API. The cost of "just exposing it as REST" is rebuilding integrations to a hundred existing tools.
So: keep OPC UA on the plant floor. If you need to expose tags to a web frontend, do it through an observability layer that bridges the two — Sutrace does this natively. We connect to your OPC UA server as a client, ingest values via subscription, and present them as time-series, alarms, and dashboards over a normal web UI. See our use case for the rollout.
Where to go from here
- Sparkplug B without the buzzword soup — the MQTT-based industrial protocol that's growing alongside OPC UA
- Sutrace as an Ignition SCADA alternative — Ignition has one of the best OPC UA implementations on the market
- Industrial monitoring for the mid-market
- open62541 — the open-source C library
- Prosys OPC UA Simulation Server — free dev server
- UaExpert from Unified Automation — the GUI client every OPC UA dev uses
- Inductive Automation forum thread on alternatives — an honest cross-section of what working integrators recommend
The deeper point
OPC UA looks intimidating because the spec is huge and the certificate handshake is painful in week one. But the conceptual core — session, browse, read, subscribe — is small enough to fit in a 50-line client. The reason it has 1,400 pages of spec is that 30 years of industrial automation experience are encoded in it. You don't need to read all of them. You need to read the 50 that cover sessions, monitored items, and the security model. Everything else, you'll learn when you need it.
If your job is "ingest tags from a plant floor and display them in a dashboard," you're 90% of the way there.