Building a Digital Twin with DTDL v3 and Azure Digital Twins: End-to-End Tutorial (2026)

Building a Digital Twin with DTDL v3 and Azure Digital Twins: End-to-End Tutorial (2026)

Building a Digital Twin with DTDL v3 and Azure Digital Twins: End-to-End Tutorial (2026)

Last Updated: September 2026

Most digital twin tutorials stop at the moment the first twin appears in a viewer. The hard parts arrive afterwards: a model that survives its second version, telemetry that lands on the right twin without melting your operations budget, and queries that still return in seconds when the graph holds a million nodes. This guide builds a complete DTDL v3 Azure Digital Twins solution from an empty subscription: four models, a factory graph, an IoT Hub to Event Grid to Function ingestion path, graph queries, history in Azure Data Explorer, and a 3D scene.

It matters now because the ground has shifted around the service. Microsoft has a separate, preview digital twin builder inside Fabric, IoT Operations has become the edge data plane, and DTDL v3 is the recommended dialect. The service itself remains generally available, but the boundaries of what it does, and does not do, are sharper than the marketing suggests. You leave with runnable code, a mental model of what belongs in the twin versus in the time-series store, and a decision framework for when to choose something else.

What this covers: the current state of the service, a DTDL v3 primer with the real differences from v2, a full model set, ingestion code, the query language including MATCH, history and 3D, cost and limit arithmetic, failure modes, and FAQs.

What Changed for September 2026

This is a full rewrite of the earlier version of this tutorial. The most important changes, all verified against Microsoft Learn and the DTDL specification in September 2026, are:

  • DTDL v3 is the default. Every model in this post uses "@context": "dtmi:dtdl:context;3". Azure Digital Twins accepts v2 and v3 side by side, and Microsoft’s documentation recommends v3 for new work.
  • Commands are not supported. The earlier version implied you could invoke commands on a twin. Azure Digital Twins ignores DTDL commands entirely. The correction is spelled out in the primer below, along with what to do instead.
  • Semantic types moved to an extension. In v3, temperature, pressure and similar types come from the QuantitativeTypes extension, and you must declare it in the context array. Older snippets that used bare semantic types will fail validation.
  • Query syntax is richer than JOIN. The MATCH clause supports direction, multiple relationship names and up to ten hops. The earlier post relied only on JOIN, and one example used a dotted path that the language does not accept.
  • Data history is generally available. It streams twin property changes to Azure Data Explorer through Event Hubs, and an Azure Digital Twins query plugin lets KQL reach back into the graph.
  • SDK baseline. The Python data-plane package azure-digitaltwins-core is at version 1.3.0 (published July 2025 at the time of my check) and defaults to service API version 2023-10-31. The bulk Import Jobs API can load models, twins and relationships from NDJSON.
  • Fabric digital twin builder is a different product. Microsoft states explicitly that the Fabric item, still in preview, is different from Azure Digital Twins, and its documentation does not mention DTDL.
  • 3D Scenes Studio is still in preview, with a 100 MB file limit per scene.

If you built against the older article, the biggest practical impacts are the context array, the removal of any command-based design, and the switch to MATCH for multi-hop traversal.

Context and Background

Azure Digital Twins is a platform-as-a-service graph database with opinions. You describe the kinds of things in your environment as DTDL interfaces, create twins as instances of those interfaces, connect them with typed relationships, and ask questions with a SQL-like query language. The service stores current state, not history, and it does not ingest telemetry by itself. Everything else, including how data arrives and where it goes afterwards, is glue you build from IoT Hub, Event Grid, Functions and Event Hubs.

That design is deliberate. The Digital Twins Definition Language began life as the modelling language of IoT Plug and Play and was then generalised for whole environments such as buildings, factories and energy networks. DTDL is JSON-LD, which means every element has an identifier and a type that machines can resolve. It is an open specification maintained in the opendigitaltwins-dtdl repository, and the parser is open source as well. You can use the language without the service, which is one of its underrated properties.

The standards landscape around it is crowded. Asset Administration Shell, OPC UA information models, W3C Web of Things Thing Descriptions and ISO 23247 all overlap with DTDL in some way. Our comparison of AAS, DTDL and OPC UA information models covers how the modelling philosophies differ, and the overview of digital twin standards including ISO 23247 and ISO/IEC 30173 places DTDL in that wider frame. The short version: DTDL is a pragmatic, cloud-first language that is easy to author and query, while the industrial standards are richer in domain semantics and lifecycle governance.

Where does Azure Digital Twins sit in Microsoft’s own portfolio? IoT Hub is the cloud device gateway. Azure IoT Operations is the edge data plane running on Arc-enabled Kubernetes, with Azure Device Registry holding asset and device definitions. Fabric Real-Time Intelligence includes a digital twin builder, currently in preview, that models entities through a low-code ontology and stores data in OneLake. Azure Digital Twins is the mature, API-first graph service you drive from code. Microsoft’s own IoT service overview describes it as typically part of a cloud-based solution built around IoT Hub, and treats IoT Operations as the edge side of the story.

For readers comparing hyperscaler options, MindSphere vs AWS IoT SiteWise vs Azure IoT Hub covers the platform trade-offs, and Eclipse Ditto is the open-source counterpart if you need to avoid a single-cloud dependency. The rest of this article assumes you have decided that Azure is the right home and want to build well.

How DTDL v3 and Azure Digital Twins Fit Together

DTDL v3 defines the schema of your twins as JSON-LD interfaces, and Azure Digital Twins hosts instances of those interfaces as a queryable graph. Properties hold current state, relationships form the edges, components embed sub-structures, and telemetry is declared but never stored. Data arrives through your own ingestion code, and history lives in Azure Data Explorer through data history.

The reference architecture used throughout this tutorial is shown below. Devices publish to IoT Hub. An Event Grid subscription forwards each telemetry event to a Function, which patches the matching twin. Azure Digital Twins then emits change events through an event route, and Event Hubs carries them into Azure Data Explorer for history, while 3D Scenes Studio and Explorer read live state from the graph.

DTDL v3 Azure Digital Twins reference architecture from IoT Hub through Event Grid and Functions to the twin graph, Data Explorer and 3D Scenes Studio

Figure 1: End-to-end reference architecture for a DTDL v3 Azure Digital Twins solution. Solid lines are the write path, and the route to Event Hubs and Data Explorer is the history path.

Read the diagram left to right. The ingestion half is generic Azure eventing and has nothing to do with twins until the Function runs. The twin half is where Azure Digital Twins earns its keep: it validates every patch against the DTDL model, keeps the relationship graph consistent, and fans out change notifications. The two halves meet in a single, deliberately thin place, the Function, so that mapping logic stays under your control.

The DTDL v3 building blocks

A DTDL interface is a JSON object with an @id (a DTMI, or Digital Twin Model Identifier such as dtmi:com:iotdtplm:factory:Machine;1), an @type of Interface, an @context, and a contents array. Inside contents you can place five element kinds.

Properties describe state. They have a schema (a primitive, or an Array, Enum, Map or Object), and Azure Digital Twins persists their values on the twin. Telemetry describes a stream of events, such as a spindle-load sample. The service does not persist telemetry. It only validates and forwards it if you publish it through the API. Relationships are typed, directed edges to other twins, referenced by target DTMI, and they can carry their own properties. Components embed another interface by value, so the component exists only inside its parent. Commands describe invocable operations, and this is where the older article was wrong: Azure Digital Twins does not support commands. The language defines them, but Microsoft’s model documentation lists commands as unsupported, so do not design around invoking one through the twin. Route commands through IoT Hub direct methods or cloud-to-device messages instead, and keep the twin as the record of intent.

Two more attributes are in the same category of “accepted but not enforced”. The writable flag on properties and relationships can be set, yet every property remains patchable by anyone with write permission. The minMultiplicity and maxMultiplicity limits on relationships are likewise not enforced. Treat them as documentation, and enforce cardinality in your ingestion or provisioning code.

What actually changed between v2 and v3

The differences matter because you can mix versions inside one instance, with directional rules. A v3 interface can extend a v2 interface and can use a v2 component. A v2 interface cannot extend a v3 interface or contain a v3 component. Relationships work across versions in both directions. That makes incremental migration possible: convert leaf models first, then the parents.

Aspect DTDL v2 DTDL v3
Context string dtmi:dtdl:context;2 dtmi:dtdl:context;3
Array as a property schema Not supported in the service Supported
Interfaces per extends Up to 2 Unlimited (depth still capped at 10)
Semantic types (Temperature, Pressure) Built in Optional QuantitativeTypes extension
Command payloads CommandPayload CommandRequest and CommandResponse
DTMI versions Single integer version Versionless allowed, or major.minor
Size accounting Separate limits per set One limit of 100,000 elements per interface
Explorer tooling Full Partial: view only, no model graph, no import

The final row is the one that trips people up. Azure Digital Twins Explorer can display v3 models and edit twins that use them, but it cannot import v3 models and does not draw them in the Model Graph panel. Upload through the CLI, SDK or Import Jobs API and treat Explorer as a viewer.

Why the twin should not store your time series

It is tempting to overwrite temperatureC at sensor rate and treat the twin as a live dashboard. Resist that. The twin stores the latest value and a small metadata block, such as lastUpdateTime and an optional sourceTime. Queries reflect changes only after a delay of up to roughly ten seconds according to the documentation, so the graph is not a hard real-time surface. If you need instant reads, call the twin GET API directly, and if you need trends, use data history. Our Azure time-series database architecture guide covers how Data Explorer complements a graph store.

A useful rule of thumb: put a value on the twin if a graph query or a scene needs to filter, colour or group by it. Keep everything else in the time-series store. State such as operational status, active alarms, firmware version and a smoothed temperature belongs on the twin. Raw vibration waveforms do not.

Limits that shape the design

Some numbers from the service limits page decide how you model. An instance defaults to 2 million twins and 20 million relationships, though both are adjustable. A single twin is capped at 32 KB of JSON and a string property at 4 KB. A model can be at most 1 MB, an instance can hold 10,000 models, and each twin can have up to 50,000 incoming and 50,000 outgoing relationships. The default patch rate is 1,000 requests per second per instance, create and delete is 500 operations per second, and queries are capped at 500 requests per second and 4,000 query units per second. These are the defaults quoted in the Microsoft limits reference at the time of writing, and many are adjustable through a support request. A query can contain at most five JOINs, 50 AND or OR expressions and 8,000 characters by default. The service auto-scales behind these numbers, but scaling takes several minutes, so bursty workloads can see HTTP 429 responses with a Retry-After header first.

Walk-through: Build the Factory Twin Step by Step

The scenario is a small factory. A site has production lines, a line has machines, each machine has an embedded controller component, and machines can feed each other along the line. That is enough structure to exercise inheritance, components, relationship properties, arrays and semantic types, without inventing anything exotic. All numbers in this walk-through are illustrative.

Step 1: Create the instance and grant yourself access

You need the Azure CLI with the azure-iot extension, which provides the az dt command group. The extension installs on first use, and the documented minimum is a recent 2.x release, so run az extension update --name azure-iot before starting. Region and names below are placeholders.

az login
az extension add --name azure-iot --upgrade

RG=rg-dtdl-demo
DT=adt-factory-demo-001          # must be globally unique
LOC=westeurope

az group create -n $RG -l $LOC
az dt create -n $DT -g $RG -l $LOC --mi-system-assigned

# Data plane access is a separate role from Azure RBAC ownership
ME=$(az ad signed-in-user show --query id -o tsv)
az dt role-assignment create -n $DT -g $RG --assignee $ME \
  --role "Azure Digital Twins Data Owner"

Two details save an hour of confusion. First, being subscription owner does not let you call the data plane. You need Azure Digital Twins Data Owner or Data Reader on the instance, and role propagation can take a few minutes. Second, the --mi-system-assigned flag gives the instance a managed identity, which data history and the Import Jobs API both require later.

Step 2: Author the DTDL v3 models

Four interfaces cover the scenario. An abstract Asset base carries an asset tag, and Site, Line and Machine extend it. Machine embeds a Controller component, declares a semantic temperature property using the QuantitativeTypes extension, includes an array property (only possible in v3), and has a feeds relationship with its own property. Save this as models.json, a single JSON array, which the CLI and SDK both accept.

[
  {
    "@context": "dtmi:dtdl:context;3",
    "@id": "dtmi:com:iotdtplm:factory:Asset;1",
    "@type": "Interface",
    "displayName": "Asset",
    "contents": [
      { "@type": "Property", "name": "assetTag", "schema": "string" }
    ]
  },
  {
    "@context": "dtmi:dtdl:context;3",
    "@id": "dtmi:com:iotdtplm:factory:Controller;1",
    "@type": "Interface",
    "displayName": "Controller",
    "contents": [
      { "@type": "Property", "name": "firmwareVersion", "schema": "string" },
      { "@type": "Property", "name": "lastBootTime", "schema": "dateTime" }
    ]
  },
  {
    "@context": "dtmi:dtdl:context;3",
    "@id": "dtmi:com:iotdtplm:factory:Site;1",
    "@type": "Interface",
    "displayName": "Site",
    "extends": "dtmi:com:iotdtplm:factory:Asset;1",
    "contents": [
      { "@type": "Property", "name": "timeZone", "schema": "string" },
      {
        "@type": "Relationship",
        "name": "hasLine",
        "target": "dtmi:com:iotdtplm:factory:Line;1"
      }
    ]
  },
  {
    "@context": "dtmi:dtdl:context;3",
    "@id": "dtmi:com:iotdtplm:factory:Line;1",
    "@type": "Interface",
    "displayName": "Line",
    "extends": "dtmi:com:iotdtplm:factory:Asset;1",
    "contents": [
      { "@type": "Property", "name": "targetRatePerHour", "schema": "integer" },
      {
        "@type": "Relationship",
        "name": "hasMachine",
        "target": "dtmi:com:iotdtplm:factory:Machine;1",
        "properties": [
          { "@type": "Property", "name": "installedOn", "schema": "date" }
        ]
      }
    ]
  },
  {
    "@context": [
      "dtmi:dtdl:context;3",
      "dtmi:dtdl:extension:quantitativeTypes;1"
    ],
    "@id": "dtmi:com:iotdtplm:factory:Machine;1",
    "@type": "Interface",
    "displayName": "Machine",
    "extends": "dtmi:com:iotdtplm:factory:Asset;1",
    "contents": [
      {
        "@type": "Property",
        "name": "operationalStatus",
        "schema": {
          "@type": "Enum",
          "valueSchema": "integer",
          "enumValues": [
            { "name": "stopped", "enumValue": 0 },
            { "name": "running", "enumValue": 1 },
            { "name": "faulted", "enumValue": 2 }
          ]
        }
      },
      {
        "@type": ["Property", "Temperature"],
        "name": "temperatureC",
        "schema": "double",
        "unit": "degreeCelsius"
      },
      { "@type": "Property", "name": "vibrationRmsMmS", "schema": "double" },
      { "@type": "Property", "name": "spindleHours", "schema": "long" },
      {
        "@type": "Property",
        "name": "activeAlarms",
        "schema": { "@type": "Array", "elementSchema": "string" }
      },
      { "@type": "Telemetry", "name": "spindleLoad", "schema": "double" },
      {
        "@type": "Component",
        "name": "controller",
        "schema": "dtmi:com:iotdtplm:factory:Controller;1"
      },
      {
        "@type": "Relationship",
        "name": "feeds",
        "target": "dtmi:com:iotdtplm:factory:Machine;1",
        "properties": [
          { "@type": "Property", "name": "transferLatencyMs", "schema": "integer" }
        ]
      }
    ]
  }
]

DTDL v3 model composition for Azure Digital Twins showing Site, Line and Machine extending Asset, with a Controller component and a feeds relationship

Figure 2: The DTDL v3 model set. Solid arrows are extends, labelled arrows are relationships, and the diamond-style link to Controller is a by-value component.

A few modelling choices deserve justification. The enum uses an integer value schema because integers patch and compare cheaply, and the human-readable names live in the model rather than in every message. The temperatureC property carries the Temperature semantic type with unit degreeCelsius, which is the only reason the second context entry exists. Semantic types are optional, but they let downstream tools understand that a number is a temperature rather than a bare double. The relationship-level property installedOn demonstrates that edges carry data: installation date belongs to the pairing of a line and a machine, not to either alone.

Notice also what the model deliberately omits. There is no Command, because the service does not support it. There is no property for raw vibration samples. And the feeds relationship targets Machine;1 itself, which is legal, and lets you express a chain along the line.

Validate before you upload. The open-source DTDLParser NuGet package resolves references and checks the semantic rules offline, so you catch a missing extension context or a dangling DTMI in seconds rather than in a failed service call.

Step 3: Upload the models

az dt model create -n $DT --models ./models.json
az dt model list -n $DT --query "[].id" -o tsv

Order does not matter within one upload, because the service resolves dependencies among the models you submit together. Models are immutable once uploaded, and the service refuses to re-upload identical content. To change a model you either publish a new version, for example Machine;2, and migrate twins by patching /$metadata/$model, or you delete and re-upload the same identifier, which switches every twin at once after a cache refresh of roughly ten to fifteen minutes and may leave some twins non-writable if properties no longer match. For anything beyond a few dozen models, the Import Jobs API accepts an NDJSON file with Header, Models, Twins and Relationships sections from blob storage, reorders models to satisfy dependencies, and allows one bulk job at a time per instance. Bulk jobs are not atomic and have no rollback.

Step 4: Create twins and relationships with the Python SDK

Install the data-plane client with pip install azure-digitaltwins-core azure-identity. Authentication goes through DefaultAzureCredential, which picks up your az login session locally and a managed identity in Azure. Every property must be initialised at creation, and a component needs an empty $metadata object, because later patches use replace operations.

import os
from azure.identity import DefaultAzureCredential
from azure.digitaltwins.core import DigitalTwinsClient

ADT_URL = os.environ["ADT_SERVICE_URL"]   # https://<name>.api.<region>.digitaltwins.azure.net
client = DigitalTwinsClient(ADT_URL, DefaultAzureCredential())

NS = "dtmi:com:iotdtplm:factory"
SITE, LINE, MACHINE = f"{NS}:Site;1", f"{NS}:Line;1", f"{NS}:Machine;1"

client.upsert_digital_twin("site-hyd-01", {
    "$metadata": {"$model": SITE},
    "assetTag": "SITE-HYD-01",
    "timeZone": "Asia/Kolkata",
})
client.upsert_digital_twin("line-a", {
    "$metadata": {"$model": LINE},
    "assetTag": "LINE-A",
    "targetRatePerHour": 120,
})

for n in (1, 2, 3):
    client.upsert_digital_twin(f"cnc-0{n}", {
        "$metadata": {"$model": MACHINE},
        "assetTag": f"CNC-000{n}",
        "operationalStatus": 1,
        "temperatureC": 40.0,
        "vibrationRmsMmS": 1.0,
        "spindleHours": 1000 * n,
        "activeAlarms": [],
        "controller": {"$metadata": {}, "firmwareVersion": "4.2.1"},
    })

def link(source, name, target, **props):
    rel_id = f"{source}-{name}-{target}"
    client.upsert_relationship(source, rel_id, {
        "$relationshipId": rel_id,
        "$sourceId": source,
        "$relationshipName": name,
        "$targetId": target,
        **props,
    })

link("site-hyd-01", "hasLine", "line-a")
for n in (1, 2, 3):
    link("line-a", "hasMachine", f"cnc-0{n}", installedOn="2025-03-01")
link("cnc-01", "feeds", "cnc-02", transferLatencyMs=35)
link("cnc-02", "feeds", "cnc-03", transferLatencyMs=80)

upsert_digital_twin has create-or-replace semantics, so the script is safe to re-run in a development instance, although replace will wipe live values on a twin that is already receiving telemetry. Use it for provisioning, and use patches for runtime updates. The $relationshipId only needs to be unique per source twin, and I build it from the triple so that re-running the script cannot create duplicates. The service validates each relationship against the model: a hasMachine edge pointing to a Site twin is rejected.

You can confirm the graph with the CLI before wiring ingestion:

az dt twin query -n $DT --query-command "SELECT COUNT() FROM DIGITALTWINS"
az dt twin show -n $DT --twin-id cnc-01

Step 5: Ingest telemetry from IoT Hub into the twin

Azure Digital Twins does not connect to devices. The pattern in Microsoft’s own tutorial is an IoT Hub Event Grid subscription that triggers a Function, and that Function patches the twin. It keeps the mapping logic in code you own, which is where it belongs, because device identifiers rarely match twin identifiers in real estates.

IoT Hub to Azure Digital Twins ingestion sequence: device message, Event Grid delivery, Function validation and JSON patch, twin update and change event

Figure 3: Sequence of a single telemetry message through IoT Hub, Event Grid and a Function into an Azure Digital Twins property patch, and the change event that follows.

First create the hub, register one device per machine, and give the Function app its identity. The device ID equals the twin ID here, which is the simplest mapping.

HUB=iot-factory-demo-001
FN=func-dtdl-ingest-001

az iot hub create -n $HUB -g $RG --sku S1 --location $LOC
az iot hub device-identity create -n $HUB -d cnc-01

az functionapp identity assign -g $RG -n $FN
PRINCIPAL=$(az functionapp identity show -g $RG -n $FN --query principalId -o tsv)
az dt role-assignment create -n $DT -g $RG --assignee $PRINCIPAL \
  --role "Azure Digital Twins Data Owner"
az functionapp config appsettings set -g $RG -n $FN \
  --settings "ADT_SERVICE_URL=https://<your-adt-hostname>"

The Function below uses the Python v2 programming model with an Event Grid trigger. It treats the incoming JSON as untrusted, whitelists the properties it will write, and stamps sourceTime from the IoT Hub enqueue time so that downstream history reflects when the device produced the reading, not when the Function ran. The client is created once at module level so warm invocations reuse the credential and HTTP connection.

import json
import logging
import os

import azure.functions as func
from azure.core.exceptions import HttpResponseError, ResourceNotFoundError
from azure.digitaltwins.core import DigitalTwinsClient
from azure.identity import DefaultAzureCredential

app = func.FunctionApp()
client = DigitalTwinsClient(os.environ["ADT_SERVICE_URL"], DefaultAzureCredential())

# device message key -> twin property. Anything not listed is ignored.
FIELD_MAP = {
    "temperatureC": "temperatureC",
    "vibrationRmsMmS": "vibrationRmsMmS",
    "operationalStatus": "operationalStatus",
    "spindleHours": "spindleHours",
}


@app.event_grid_trigger(arg_name="event")
def iothub_to_twin(event: func.EventGridEvent) -> None:
    if event.event_type != "Microsoft.Devices.DeviceTelemetry":
        return
    data = event.get_json()
    sysprops = data.get("systemProperties", {})
    twin_id = sysprops.get("iothub-connection-device-id")
    body = data.get("body")
    if isinstance(body, str):          # base64 when contentEncoding was not set
        logging.warning("Non-JSON body from %s, skipping", twin_id)
        return
    enqueued = sysprops.get("iothub-enqueuedtime")

    patch = []
    for key, prop in FIELD_MAP.items():
        if key in body:
            patch.append({"op": "replace", "path": f"/{prop}", "value": body[key]})
            if enqueued:
                patch.append({
                    "op": "replace",
                    "path": f"/$metadata/{prop}/sourceTime",
                    "value": enqueued,
                })
    if not patch:
        return
    try:
        client.update_digital_twin(twin_id, patch)
    except ResourceNotFoundError:
        logging.error("No twin named %s, dropping message", twin_id)
    except HttpResponseError as err:
        logging.error("Patch rejected for %s: %s", twin_id, err.message)
        raise                           # let Event Grid retry transient failures

Then subscribe the Function to hub telemetry:

az eventgrid event-subscription create --name hub-telemetry-to-twins \
  --event-delivery-schema eventgridschema \
  --source-resource-id $(az iot hub show -n $HUB -g $RG --query id -o tsv) \
  --included-event-types Microsoft.Devices.DeviceTelemetry \
  --endpoint-type azurefunction \
  --endpoint $(az functionapp show -g $RG -n $FN --query id -o tsv)/functions/iothub_to_twin

The device side must set the content type and encoding. IoT Hub only writes a readable JSON body into the Event Grid event when the message has contentType of application/json and contentEncoding of UTF-8. Without them the body arrives base64 encoded, the Function above skips it, and you will spend an evening wondering why nothing updates. Test with one message from the CLI extension or a device SDK, then check the twin with az dt twin show.

Event Grid delivers at least once and does not guarantee ordering, and Microsoft’s own IoT Hub documentation says telemetry events can arrive out of order or late. That has a concrete consequence: a late message can overwrite a newer value. Two mitigations work in practice. First, compare sourceTime in the twin’s metadata with the message’s enqueue time and drop stale data. Second, use the twin’s $etag with an If-Match condition on the patch when order truly matters. For high-volume telemetry, consider replacing Event Grid with the hub’s Event Hubs-compatible endpoint and an Event Hubs trigger, which gives partition-ordered, batched reads. Our Service Bus versus Event Hubs comparison explains why partitioned streams suit this job.

Step 6: Publish change events through an event route

Ingestion covers writes. To push changes out, create an endpoint and a route. Azure Digital Twins supports up to six endpoints and, by default, six routes per instance, with targets including Event Grid, Event Hubs and Service Bus. Filters keep you from paying to route events nobody consumes.

az eventgrid topic create -n egt-twin-events -g $RG -l $LOC
az dt endpoint create eventgrid -n $DT -g $RG \
  --endpoint-name ep-events --eventgrid-resource-group $RG \
  --eventgrid-topic egt-twin-events

az dt route create -n $DT -g $RG --endpoint-name ep-events \
  --route-name route-updates \
  --filter "type = 'Microsoft.DigitalTwins.Twin.Update'"

The flags on the az dt route and az dt endpoint groups have changed between releases of the azure-iot extension, so run az dt route create -h if a parameter is rejected. The filter itself is standard: the route emits only twin update notifications, not lifecycle events or relationship changes.

Routes are also how declared telemetry becomes useful. If your gateway calls client.publish_telemetry("cnc-01", {"spindleLoad": 0.71}), the service validates the payload against the model’s Telemetry element and forwards it to routes without storing it. That is the right path for high-rate signals that a consumer needs to see once and nothing else needs to keep.

Querying the Twin Graph

The query language is SQL-like and runs against current state. It is case sensitive, and a misspelled property name returns an empty result rather than an error, which is the single most common debugging trap. Single quotes inside strings are escaped with a backslash. Start with model-based filtering:

SELECT M FROM DIGITALTWINS M
WHERE IS_OF_MODEL(M, 'dtmi:com:iotdtplm:factory:Machine;1')
  AND M.temperatureC > 80

IS_OF_MODEL matches the model and everything that extends it. Add the exact argument if you only want twins of precisely that interface. For relationships, the language offers two constructs. The older JOIN ... RELATED works well for one hop and lets you alias the relationship to filter on its properties:

SELECT M1, M2, R FROM DIGITALTWINS M1
JOIN M2 RELATED M1.feeds R
WHERE R.transferLatencyMs > 50

The MATCH clause handles multi-hop patterns with direction, multiple relationship names and hop counts. The pattern below returns hot machines anywhere under one site, walking Site to Line to Machine:

SELECT Line, M FROM DIGITALTWINS
MATCH (Site)-[:hasLine]->(Line)-[:hasMachine]->(M)
WHERE Site.$dtId = 'site-hyd-01' AND M.temperatureC > 80

MATCH has firm rules. Only one MATCH is allowed per query, a $dtId filter is required, the maximum is ten hops, and relationship variables such as Rel work only for single hops. In practice this means you anchor on a known twin and fan out, which suits tree-like hierarchies but is not a general graph analytics engine. A variable-length pattern like MATCH (Site)-[*1..3]->(M) combined with IS_OF_MODEL(M, ...) answers “everything within three hops” without naming each relationship.

From Python, query_twins returns a lazy pager of dictionaries, and with alias projections each row is keyed by the alias:

hot = client.query_twins(
    "SELECT M FROM DIGITALTWINS M "
    "WHERE IS_OF_MODEL(M, 'dtmi:com:iotdtplm:factory:Machine;1') "
    "AND M.temperatureC > 80"
)
for row in hot:
    twin = row["M"]
    print(twin["$dtId"], twin["temperatureC"])

Query cost is measured in query units, and the default pool is 4,000 units per second per instance. A query that touches every twin, or a MATCH over a wide fan-out, spends far more than a point lookup. For a value you know by ID, call the get-twin API. It is cheaper and immediate, whereas queries can lag changes by several seconds.

History, Analytics and 3D: Getting Beyond Current State

Data history to Azure Data Explorer

Because the graph holds only current values, the standard answer to “what was the temperature at 02:00 yesterday” is data history. It is a generally available feature that captures twin property updates, twin lifecycle events and relationship lifecycle events, routes them through an Event Hubs instance, and writes them into an Azure Data Explorer database. You need the instance’s system-assigned managed identity, an Event Hubs namespace with an event hub, and an Azure Data Explorer cluster with public network access, plus role assignments: Event Hubs Data Owner while creating the connection, Data Sender at runtime, and contributor and database admin rights on the Data Explorer side. Create the connection with the az dt data-history connection create adx command, and consult its -h output for the exact parameter names in your extension version, since I could not verify them against a live tenant.

The result is three tables. By default AdtPropertyEvents holds one row per changed property with the timestamp, the SourceTimeStamp (which is why we patched sourceTime earlier), the twin ID, the model ID, the property key and a dynamic value. Separate tables hold twin and relationship lifecycle events. Documented ingestion latency from the service to Data Explorer is under two seconds, and total latency depends on the ingestion mode: streaming ingestion lands within roughly twelve seconds and is suited to volumes below about 4 GB per hour, while the default batching mode can take from twelve seconds to fifteen minutes and suits larger volumes.

The query plugin closes the loop. It lets a KQL query call the graph, so you can select twins by topology and join them to history. Two restrictions apply: no SELECT *, and no column names starting with $, so alias system properties.

let hotCandidates = evaluate azure_digital_twins_query_request(
    "https://<your-adt-hostname>",
    "SELECT M.$dtId AS tid FROM DIGITALTWINS M
     WHERE IS_OF_MODEL(M, 'dtmi:com:iotdtplm:factory:Machine;1')");
hotCandidates
| join kind=inner (
    AdtPropertyEvents
    | where Key == "temperatureC" and TimeStamp > ago(24h)
  ) on $left.tid == $right.Id
| summarize avgTemp = avg(todouble(Value)) by tid, bin(TimeStamp, 15m)
| order by tid asc, TimeStamp asc

The pattern is the payoff of the whole architecture: the graph decides which things matter, and the time-series engine crunches their history. If a machine is reassigned to another line, the topology query changes and the history query needs no edit.

3D Scenes Studio

3D Scenes Studio, still in preview, lets you attach a glTF or GLB file to your twins. You import a 3D file up to 100 MB, map named meshes to twins in a low-code builder, and define behaviours: colour rules such as “turn the mesh red when operationalStatus equals 2″, and widgets that show property values. The studio stores the scene as two files in a private blob container, the 3D file and a generated configuration file, so you need a storage account with CORS enabled and the right blob roles. It refreshes on a baseline of ten seconds plus about half a second per linked twin, which the documentation illustrates as fifteen seconds for thirty twins, and it is subject to the same API rate limits as everything else.

That refresh model tells you what the scene is for: situational awareness for operators, not sub-second animation. If you need real-time visual fidelity, look at OpenUSD-based pipelines. Our guide to OpenUSD industrial digital twin architecture and the Omniverse factory blueprint cover that route, and DTDL can still serve as the semantic layer behind them.

Where DTDL sits next to WoT, IoT Operations and Fabric

Three neighbours come up in every design review. The W3C Web of Things Thing Description is the standards-body equivalent for describing device capabilities. Version 1.1 is a W3C Recommendation, and version 2.0 was a working draft at the end of 2025. Microsoft has published material about WoT support in Azure IoT Operations, dated April 2026 on its Tech Community blog. I could read only the headline and date, so I will not characterise its details. DTDL began in the same problem space, and there is an old, inconclusive discussion in the DTDL repository about mapping the two. In practice, treat WoT as the interoperability format at the device and edge boundary, and DTDL as the modelling format inside the Azure Digital Twins service.

Azure IoT Operations models assets through Azure Device Registry, as datasets, event groups, management groups and streams organised into namespaces. Its asset overview does not mention DTDL at all. So do not assume that an IoT Operations asset definition is a DTDL interface. You will typically map them in code.

Fabric’s digital twin builder is different again: a low-code ontology tool inside Real-Time Intelligence, in preview, storing data in OneLake. Microsoft’s documentation says it is different from Azure Digital Twins, and it does not reference DTDL. If your users live in Fabric and Power BI and your twin needs are mostly semantic mapping of analytical data, evaluate it. If you need a programmable, event-driven graph with a mature API and per-twin patch semantics, Azure Digital Twins is still the product built for it.

Costs and Capacity: Worked Numbers

Azure Digital Twins bills on three meters: operations (API calls, counted in 1 KB increments of response body size), messages (event route notifications to Event Grid, Event Hubs or Service Bus, in 1 KB increments) and query units. There is no charge for an idle instance. I am deliberately not quoting unit prices, because they vary by region and change; use the Azure pricing calculator with the counts below.

Consider an illustrative plant with 1,000 machines. Each publishes a reading every 10 seconds, and the Function writes one patch per reading. That is 100 patches per second, or about 8.64 million operations per day and roughly 259 million per month, because a patch touching several properties in one call still counts as one operation. The headroom is fine: 100 per second is a tenth of the 1,000 per second default patch limit. If every update also matches a route, you double the volume by adding roughly the same count of billable messages. The lever is therefore the ingestion design, not the price list.

Three changes cut that bill sharply. Write on change only: if temperature moves under half a degree, skip the patch. Batch properties in one patch instead of one call per field. And filter routes to the event types a consumer needs. Suppose 90 percent of readings are unchanged; the monthly operation count drops from about 259 million to about 26 million. Those figures are arithmetic on assumed inputs, not measurements.

Latency is a different budget. Add Event Grid delivery, Function scheduling and the patch call, and expect end-to-end delays of seconds rather than milliseconds, plus the up to ten seconds before queries reflect a change. Cold starts on consumption-plan Functions add more to the first message after idle. I have not benchmarked these, so measure your own path before promising a service level.

Trade-offs, Gotchas, and What Goes Wrong

The twin is not a historian. The most common architectural error is patching sensor-rate data into properties and then querying for trends. You pay for every operation, the query lag makes the graph useless as a live scope, and history disappears with each overwrite. Decide up front which fields are state and which are signal.

Models are hard to change. Interfaces are immutable, and identical re-uploads are refused. Versioning by incrementing the DTMI keeps old twins valid but leaves you supporting two shapes until every twin is patched to the new model. Delete-and-reupload is faster but risky in production, since twins can become non-writable if their properties no longer match. Treat models like source code: keep them in Git, validate them in CI with the parser, and review changes as you would a database migration.

Unenforced attributes mislead. Because writable, minMultiplicity and maxMultiplicity are not enforced, a model can promise constraints that nothing checks. New team members read the model, assume the constraint holds, and build on a false guarantee. Either enforce the rules in provisioning code or remove the attributes from the model.

Unordered delivery corrupts state. Event Grid can deliver telemetry late or out of order. With a naive last-write-wins patch, an old reading from a reconnecting device can overwrite the current value and trigger a false alarm. Compare sourceTime, or use etag conditions, for properties where staleness has a cost.

Silent empty results. Case-sensitive queries return zero rows for misspelled property names. Add a smoke test to your pipeline that asserts a known twin comes back for each production query.

Limits arrive as 429s. Auto-scaling takes minutes, so a burst after an outage, when every device reconnects and flushes its buffer, can hit rate limits before capacity grows. The SDK’s default pipeline retries throttled calls, but you still want back-pressure: put a queue or an Event Hubs trigger with bounded concurrency in front of the patch call.

Explorer and v3. Do not plan a workflow around importing v3 models through Explorer. Use CI and the CLI, and keep Explorer for inspection.

Preview features. 3D Scenes Studio is in preview, and Fabric digital twin builder is in preview. Neither carries the guarantees of a generally available service. Isolate them behind an adapter so you can replace them.

Decision flow for choosing Azure Digital Twins, Fabric digital twin builder, IoT Operations or an open-source twin platform for a DTDL v3 project

Figure 4: A decision flow for picking the twin platform. Azure Digital Twins fits programmable, event-driven graphs in Azure, while other paths suit Fabric analytics, edge-first, or multi-cloud needs.

The table below condenses the choice. Scores are my qualitative judgement, not benchmarks.

Need Azure Digital Twins with DTDL v3 Fabric digital twin builder Eclipse Ditto or self-hosted graph
Programmable graph and patch API Strong Limited, low-code oriented Strong
Analytics and BI alongside twins Via data history to Data Explorer Native in OneLake and Power BI Build your own
Multi-cloud or on-premises Weak, Azure only Weak, Fabric only Strong
Maturity Generally available Preview Community and vendor dependent
DTDL as the modelling language Yes No No
Operations burden Low, managed service Low, managed High, you run it

Practical Recommendations

Start from the questions your users will ask, then work backwards to the model. If nobody asks “which machines under this line are hot”, you probably do not need a graph traversal, and a well-indexed store may be enough. If they ask it ten times a day, the graph pays for itself.

Keep models small and composable. Prefer a shallow base interface plus a handful of leaf interfaces over a deep inheritance chain, and stay well within the ten-level depth cap. Use components for structures that have no identity outside their parent, such as a controller, and relationships for anything you would ever want to query independently. Adopt an existing industry ontology where one fits your domain, since Microsoft’s guidance is to start from published DTDL ontologies rather than invent your own vocabulary.

Design ingestion as a thin, boring, testable function. Whitelist fields, stamp sourceTime, batch properties, skip unchanged values and treat message order as untrusted. Send the history path through data history rather than writing your own sink.

A short checklist before you call a twin production-ready:

  • Models validated with the DTDLParser in CI and stored in version control.
  • Every property initialised at twin creation, and a provisioning script that is idempotent.
  • Managed identity for the Function, with the narrowest data-plane role that works.
  • A route filter for each consumer, and a dead-letter or retry path for delivery failures.
  • Data history enabled, with the Data Explorer retention policy set deliberately.
  • Alerts on HTTP 429 counts and on Function failures.
  • A documented rule for which values are state and which are signal.
  • A rollback plan for model changes, including which twins move to the new version first.

Finally, connect the twin to the rest of your stack. For shop-floor context, see the CNC machine digital twin tutorial and the digital twin and MES reference architecture. Those show where the twin ends and execution systems begin.

Frequently Asked Questions

What is the difference between DTDL v2 and DTDL v3 in Azure Digital Twins?

DTDL v3 allows array properties, removes the two-interface limit on extends, counts size as one 100,000-element budget per interface, and moves semantic types into the optional QuantitativeTypes extension. You switch by changing the context to dtmi:dtdl:context;3. Azure Digital Twins accepts both versions in one instance, though a v2 interface cannot extend a v3 one. Explorer can view v3 models but cannot import them, so upload through the CLI or SDK.

Does Azure Digital Twins support DTDL commands?

No. Microsoft’s model documentation lists commands among the DTDL features the service does not support, and it says the writable attribute and the relationship multiplicity limits can be defined but are not enforced. If you need to trigger an action on a device, use IoT Hub direct methods or cloud-to-device messaging, and record the requested state as a property on the twin so the request stays auditable and queryable.

How do I get telemetry from IoT Hub into Azure Digital Twins?

The service does not ingest device data on its own. Microsoft’s pattern is an IoT Hub Event Grid subscription for Microsoft.Devices.DeviceTelemetry events that triggers a Function, which reads the device ID and body and patches the matching twin with the SDK. The Function’s managed identity needs the Azure Digital Twins Data Owner role. For higher volumes, read the hub’s Event Hubs-compatible endpoint instead. Either way, set JSON content type and UTF-8 encoding on device messages.

Can I query historical values of a twin property?

Not through the twin query language, which reads current state only. Enable data history to stream property updates through Event Hubs into Azure Data Explorer, where they land in tables such as AdtPropertyEvents. Then query with KQL and, when you need topology, call the Azure Digital Twins query plugin from the same query. Streaming ingestion typically lands within seconds, while batching can take up to fifteen minutes.

Is Azure Digital Twins being replaced by Fabric digital twin builder?

Not according to Microsoft’s documentation as of September 2026. The digital twin builder in Fabric Real-Time Intelligence is a preview, low-code ontology item that Microsoft explicitly describes as different from Azure Digital Twins, and its documentation does not mention DTDL. Azure Digital Twins remains a documented, generally available service. Choose by need: API-driven graph and events point to Azure Digital Twins, while analytics in OneLake and Power BI point to Fabric.

How many twins and requests can one instance handle?

By default an instance supports 2 million twins and 20 million relationships, with 1,000 read and patch requests per second, 500 create or delete operations per second, and 500 query requests per second at 4,000 query units per second. Most limits are adjustable by support ticket, and the service auto-scales within minutes, returning HTTP 429 with a Retry-After header when you exceed capacity. Check the current limits page before sizing, since the numbers can change.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *