Our Azure Quantum guide for Qiskit covers the most direct integration path: a provider object that looks and behaves like any other Qiskit backend. Cirq and PennyLane both reach Azure Quantum too, but through noticeably different mechanisms from each other and from Qiskit's, worth understanding before assuming one pattern transfers to the other.
Cirq: a service object, not a provider
pip install --upgrade "qdk[azure,cirq]" ipykernel
from qdk.azure import Workspace
from qdk.azure.cirq import AzureQuantumService
workspace = Workspace(resource_id="") # your workspace's resource ID
service = AzureQuantumService(workspace)
Where Qiskit's integration gives you a provider.get_backend() call returning something that behaves like a standard Qiskit backend, Cirq's integration gives you a service object with its own run() and create_job() methods, closer to Cirq's own native execution API than to a backend abstraction.
print(service.targets())
[<Target name="quantinuum.qpu.h2-1", avg. queue time=0 s, Degraded>,
<Target name="ionq.simulator", avg. queue time=3 s, Available>,
<Target name="ionq.qpu.aria-1", avg. queue time=1136774 s, Available>]
Listing targets shows real queue times, worth checking before submitting: a queue time in the millions of seconds, as shown above for one example QPU target, means that target is effectively unavailable for anything but a long-running background job, not a hang or an error on your end.
Running a Cirq circuit
import cirq
q0, q1 = cirq.LineQubit.range(2)
circuit = cirq.Circuit(
cirq.X(q0) ** 0.5,
cirq.CX(q0, q1),
cirq.measure(q0, q1, key="b"),
)
result = service.run(program=circuit, repetitions=100, target="ionq.simulator")
print(result)
This returns a cirq.Result object when run through service.run() directly, the object type Cirq code normally expects.
The result type changes with the asynchronous path
job = service.create_job(program=circuit, repetitions=100, target="ionq.simulator")
result = job.results()
Using the asynchronous create_job()/job.results() path instead of the direct service.run() call returns a provider-specific result object, not a cirq.Result, one of the sharper edges in this integration. On the IonQ simulator target specifically, that means a cirq_ionq.results.SimulatorResult reporting state probabilities rather than raw shot data. Convert it explicitly when downstream code expects Cirq's native format:
cirq_result = result.to_cirq_result()
Skipping this conversion and treating the result as a cirq.Result directly is the most likely place code breaks when moving from the synchronous to the asynchronous submission pattern.
One circuit per job
jobs = [service.create_job(program=c, repetitions=100, target="ionq.simulator") for c in circuits]
results = [job.results() for job in jobs]
Submitting multiple circuits in a single job isn't supported on this integration. Looping individual create_job() calls, as above, and collecting results afterward is the documented workaround, worth knowing before assuming a batch-submission API exists.
PennyLane: no device plugin, a compile pipeline instead
pip install --upgrade "qdk[azure]" pennylane
PennyLane's other backends typically work through qml.device('vendor.device', ...), a plugin abstraction that hides the transport details. Azure Quantum's PennyLane path doesn't follow that pattern. Instead, you build a circuit normally with PennyLane, then explicitly compile it down through OpenQASM and QIR before submitting:
import pennylane as qml
from qdk.openqasm import compile
from qdk.azure import Workspace
from qdk import TargetProfile
device = qml.device("default.qubit", wires=2)
@qml.qnode(device)
def circuit(theta):
qml.H(0)
qml.CNOT(wires=[0, 1])
qml.RY(theta, wires=1)
return qml.expval(qml.PauliZ(1))
Note the device here is PennyLane's own local simulator, default.qubit, used to build and draw the circuit. It isn't what runs on Azure Quantum hardware. The QASM/QIR conversion below is.
Compiling to QIR
theta = 0.3
qasm_str = qml.to_openqasm(circuit)(theta)
target_profile = TargetProfile.Base
qir = compile(qasm_str, target_profile)
target_profile matters here in a way it doesn't for Qiskit or Cirq's integrations: QIR target profiles constrain what a circuit contains (dynamic control flow, mid-circuit measurement, and so on) based on what the specific hardware target supports, so the profile you pick has to match what you intend to submit to. TargetProfile.Base is the most restrictive and broadly compatible option, a reasonable default until a specific target's capabilities call for something more permissive.
Submitting the compiled circuit
workspace = Workspace(resource_id="")
target = workspace.get_targets("rigetti.sim.qvm")
job = target.submit(qir, "pennylane-job", shots=100)
print(job.get_results())
Note this workflow submits pre-compiled QIR directly through target.submit(), not through anything resembling a PennyLane execution call. Once the circuit is compiled, PennyLane itself is out of the picture. Everything from here is the same target/workspace API the Cirq and Qiskit integrations both build on.
Why the three integrations diverge this much
Qiskit gets the deepest integration because Microsoft's QDK is built with it as a first-class target, close enough to a native Qiskit backend that existing Qiskit code mostly works unmodified once the provider and backend are set up. Cirq's AzureQuantumService is a purpose-built bridge, functional but not identical to Cirq's own native execution objects, as the result-type gotcha above shows. PennyLane's path is the least native of the three: rather than a device plugin, it leans on QIR as a common intermediate format, which is a broader engineering choice (QIR is meant to be a vendor-neutral compilation target across many frontends) rather than a sign PennyLane support is an afterthought, but it does mean the code looks meaningfully different from PennyLane's typical qml.device(...) pattern elsewhere.
Try this next
- Run the same circuit through both
service.run()andservice.create_job()on the Cirq path, and confirm you understand the type difference between the two result objects before writing code that assumes one or the other. - Try
TargetProfile.Baseversus a less restrictive profile on the PennyLane path against a target that supports it, and see what compile-time errors show up when a circuit uses something the stricter profile disallows. - Compare all three integrations against the same Rigetti or IonQ simulator target and note how much of the setup code (
Workspace, resource ID, target listing) is genuinely shared versus SDK-specific.