Concepts Beginner Free 12/53 in series 22 min read

Getting Started with the IonQ Python SDK

Install the IonQ Python SDK, build a circuit using native GPi and MS gates, run it on the IonQ simulator, and understand when to use the native SDK versus cirq-ionq or Qiskit on Azure.

What you'll learn

  • IonQ
  • trapped-ion
  • native gates
  • GPi
  • MS gate
  • cloud quantum computing

Prerequisites

  • Basic Python (variables, functions, loops)
  • No quantum physics background needed

The IonQ Python SDK gives you direct access to IonQ’s trapped-ion quantum hardware using the machine’s own native gate language. Circuits are expressed as JSON-like Python dictionaries with three native gates: GPi, GPi2, and MS. Using native gates directly skips the generic decomposition that higher-level SDKs apply, reducing circuit depth and accumulated error.

This tutorial covers installation, API key setup, the physics behind native gates, writing circuits, running on the simulator and hardware, benchmarking, cost management, batch submission, and common mistakes to avoid.

Trapped-Ion Physics Background

Before writing any code, it helps to understand why IonQ hardware uses these specific gates. The physical substrate determines the native operations, and working directly with native gates requires knowing what they do at the physics level.

IonQ’s qubits are individual ytterbium-171 ions held in a linear Paul trap. Oscillating electric fields confine the ions in a line, and laser cooling brings their temperature to near absolute zero. The qubit is encoded in two hyperfine energy levels of the ion’s outer electron, separated by 12.6 GHz. These states are extremely stable, with T1 times exceeding 1000 seconds.

Single-qubit gates are implemented by shining precisely tuned laser pulses on individual ions. A pulse that drives the qubit transition for exactly enough time to cause a pi rotation (a full flip) gives you the GPi gate. A pulse half that length gives GPi2. The laser’s optical phase determines the rotation axis in the equatorial plane of the Bloch sphere.

Two-qubit gates exploit the fact that all ions in the trap share collective vibrational modes (phonon modes). The Molmer-Sorensen (MS) gate applies simultaneous laser beams to two ions, coupling their internal states through these shared vibrations. The result is a maximally entangling two-qubit gate. Because the phonon bus connects all ions, any two qubits can interact directly, giving IonQ hardware all-to-all connectivity with no SWAP overhead.

This architecture produces three native operations:

  • GPi(phi): pi rotation around an axis at angle phi in the equatorial plane of the Bloch sphere
  • GPi2(phi): pi/2 rotation around the same axis
  • MS(phi0, phi1): Molmer-Sorensen entangling gate with per-qubit axis angles

Every circuit you run on IonQ hardware is ultimately executed as a sequence of these three operations.

Installation

pip install ionq

No additional dependencies are required for the simulator. The package is small and focused.

Getting an API Key

Go to cloud.ionq.com and create an account. Once logged in, navigate to API Keys and generate a new key.

Set it as an environment variable so your key is not embedded in code:

export IONQ_API_KEY="your-api-key-here"

Then in Python:

import ionq

# Reads IONQ_API_KEY from the environment automatically
client = ionq.Client()

# Or pass it directly (not recommended for shared code)
client = ionq.Client(api_key="your-api-key-here")

Understanding Turns: IonQ’s Angle Convention

IonQ uses turns as its angle unit, not radians and not degrees. This is the single most common source of errors when writing native-gate circuits.

One turn equals one full rotation:

TurnsRadiansDegreesDescription
0.000No rotation
0.25pi/290Quarter turn
0.5pi180Half turn
0.753*pi/2270Three-quarter turn
1.02*pi360Full turn (same as 0.0)

The phi parameter in GPi and GPi2 selects the rotation axis in the equatorial (XY) plane of the Bloch sphere:

phi (turns)Rotation axisEffect of GPi(phi)Effect of GPi2(phi)
0.0+X axisX gate (bit flip)(X+Y)/sqrt(2) rotation
0.25+Y axisY gate(Y-X)/sqrt(2) rotation
0.5-X axis-X gate-(X+Y)/sqrt(2) rotation
0.75-Y axis-Y gate-(Y-X)/sqrt(2) rotation

To convert from radians to turns:

import numpy as np

def radians_to_turns(radians):
    """Convert radians to turns (IonQ convention)."""
    return (radians / (2 * np.pi)) % 1.0

# Examples
print(radians_to_turns(np.pi / 2))   # 0.25
print(radians_to_turns(np.pi))       # 0.5
print(radians_to_turns(3 * np.pi))   # 0.5 (mod 1.0)

If you have a Qiskit RX(theta) gate where theta is in radians, the equivalent in IonQ native gates requires converting that angle to turns before passing it to GPi or GPi2. Forgetting this conversion and passing raw radian values is a very common mistake (covered in the Common Mistakes section below).

IonQ’s Native Gate Set

IonQ hardware uses three native gates. All angles are in turns.

GPi(phi): full pi rotation around an axis in the XY plane. The unitary matrix is:

GPi(phi) = [[0,                exp(-i*2*pi*phi)],
            [exp(i*2*pi*phi),  0               ]]

At phi=0, this is the Pauli X gate (bit flip). At phi=0.25, this is the Pauli Y gate (up to global phase).

GPi2(phi): pi/2 rotation around the same axis. The unitary is:

GPi2(phi) = (1/sqrt(2)) * [[1,                 -i*exp(-i*2*pi*phi)],
                            [-i*exp(i*2*pi*phi), 1                 ]]

This is the primary building block for state preparation and basis changes. GPi2(0) acts similarly to a Hadamard gate (it creates superposition from a computational basis state), though the matrix entries differ by phases.

MS(phi0, phi1): Molmer-Sorensen maximally entangling gate. phi0 and phi1 are per-qubit axis angles. The fully symmetric version uses phi0=0, phi1=0. The MS gate is the only two-qubit gate in the native set, and it produces maximal entanglement in a single operation.

When you run H and CX through a compiler, those abstract gates get decomposed into GPi, GPi2, and MS anyway. Using native gates directly skips that decomposition, saving gates and reducing accumulated error.

Circuit Format

IonQ circuits are Python dictionaries with two keys: qubits (an integer) and circuit (a list of gate operations).

# A single GPi2 gate on qubit 0 (puts it in superposition)
circuit = {
    "qubits": 1,
    "circuit": [
        {"gate": "gpi2", "target": 0, "phase": 0.0}
    ]
}

Gate names are lowercase strings. Parameters vary by gate type:

# Single-qubit gates: "target" and "phase"
{"gate": "gpi",  "target": 0, "phase": 0.0}
{"gate": "gpi2", "target": 1, "phase": 0.25}

# Two-qubit MS gate: "targets" (list) and "phases" (dict)
{"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}}

Note the distinction: single-qubit gates use "target" (singular) and "phase" (singular). The MS gate uses "targets" (plural, a two-element list) and "phases" (plural, a dict with "control" and "target" keys). Mixing these up causes API errors.

Manual Native Gate Compilation

If you are translating a circuit from standard gates to IonQ native gates, you need to know the decompositions. Here are the most common standard gates expressed in IonQ native gates.

Pauli X gate:

# X = GPi(0)
x_gate = [
    {"gate": "gpi", "target": 0, "phase": 0.0}
]

Pauli Y gate:

# Y = GPi(0.25)
y_gate = [
    {"gate": "gpi", "target": 0, "phase": 0.25}
]

Pauli Z gate:

# Z = GPi(0) followed by GPi(0.5)
# Equivalently: Z is a virtual gate (phase shift) that can be absorbed into adjacent GPi/GPi2 phases
z_gate = [
    {"gate": "gpi", "target": 0, "phase": 0.0},
    {"gate": "gpi", "target": 0, "phase": 0.5}
]

Hadamard gate:

# H = GPi(0) followed by GPi2(0.25)
# This gives the standard Hadamard up to global phase
hadamard = [
    {"gate": "gpi", "target": 0, "phase": 0.0},
    {"gate": "gpi2", "target": 0, "phase": 0.25}
]

CNOT gate (CX):

A CNOT between qubit 0 (control) and qubit 1 (target) decomposes into one MS gate and surrounding single-qubit gates:

# CNOT(0, 1) decomposition into native gates
cnot_01 = [
    # Pre-rotation on target
    {"gate": "gpi2", "target": 1, "phase": 0.5},
    # Entangling gate
    {"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}},
    # Post-rotations
    {"gate": "gpi", "target": 0, "phase": 0.25},
    {"gate": "gpi2", "target": 0, "phase": 0.5},
    {"gate": "gpi2", "target": 1, "phase": 0.5}
]

This uses 1 MS gate and 4 single-qubit gates, for a total of 5 native gates. Compare this to a compiler that might produce a less optimal decomposition with extra gates. When you write native gates by hand, you control the exact gate count.

RZ(theta) gate:

import numpy as np

def rz_native(target, theta_radians):
    """RZ(theta) decomposed into two GPi gates.
    theta is in radians; we convert to turns internally.
    """
    turns = (theta_radians / (2 * np.pi)) % 1.0
    half = turns / 2.0
    return [
        {"gate": "gpi", "target": target, "phase": 0.0},
        {"gate": "gpi", "target": target, "phase": half}
    ]

In practice, the IonQ compiler can absorb many single-qubit Z rotations into the phases of adjacent gates (a technique called “virtual Z gates”), eliminating them entirely. This is one reason why working in the native gate set can produce shorter circuits.

Bell State in Native Gates

A Bell state (maximally entangled two-qubit state) requires one single-qubit gate and one two-qubit entangling gate. In native IonQ gates:

import ionq

client = ionq.Client()

bell_circuit = {
    "qubits": 2,
    "circuit": [
        # GPi2(0) on qubit 0: puts qubit 0 into |+> superposition
        {"gate": "gpi2", "target": 0, "phase": 0.0},
        # MS(0,0) on qubits 0 and 1: entangling gate
        {"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}},
    ]
}

This is the minimal native-gate Bell state: just 2 gates total. The GPi2 creates a superposition on qubit 0, and the MS gate entangles both qubits. Compare this to the standard textbook Bell state circuit (H + CNOT), which when compiled to native gates produces 5+ operations. Writing native gates directly saves 3 gates and the corresponding accumulated error.

3-Qubit GHZ State

The GHZ state is the canonical multi-qubit entanglement benchmark. A 3-qubit GHZ state is an equal superposition of |000> and |111>:

|GHZ> = (|000> + |111>) / sqrt(2)

In standard gates, you build this with an H gate followed by two CNOTs. In native IonQ gates, you can build it more efficiently:

import ionq

client = ionq.Client()

ghz3_circuit = {
    "qubits": 3,
    "circuit": [
        # Step 1: Create superposition on qubit 0
        {"gate": "gpi2", "target": 0, "phase": 0.0},

        # Step 2: Entangle qubit 0 and qubit 1
        {"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}},

        # Step 3: Corrective single-qubit gate on qubit 0
        {"gate": "gpi", "target": 0, "phase": 0.25},
        {"gate": "gpi2", "target": 0, "phase": 0.5},

        # Step 4: Entangle qubit 1 and qubit 2
        {"gate": "ms", "targets": [1, 2], "phases": {"control": 0.0, "target": 0.0}},

        # Step 5: Corrective single-qubit gate on qubit 1
        {"gate": "gpi", "target": 1, "phase": 0.25},
        {"gate": "gpi2", "target": 1, "phase": 0.5},
    ]
}

# Run on the simulator
job = client.create_job(
    circuit=ghz3_circuit,
    target="simulator",
    shots=1024,
)

result = client.get_job(job["id"])
histogram = result["data"]["histogram"]

# Convert to readable counts
shots = 1024
counts = {
    format(int(s), f'0{ghz3_circuit["qubits"]}b'): round(p * shots)
    for s, p in histogram.items()
}
print(counts)
# Expected: {'000': 512, '111': 512}

The output should show only the states |000> and |111>, each with roughly 50% probability. Any other states in the histogram indicate errors (on the noise model simulator) or a bug in the circuit (on the ideal simulator).

On IonQ hardware, all-to-all connectivity means the MS gates between qubits (0,1) and (1,2) execute directly. On a superconducting processor with linear connectivity, the second CNOT might require SWAP routing through qubit 1, adding 3 extra two-qubit gates and increasing the error budget.

Running on the Simulator and Reading Results

# Requires: ionq
job = client.create_job(
    circuit=bell_circuit,
    target="simulator",
    shots=1024,
)

result = client.get_job(job["id"])
print(result["data"]["histogram"])
# {'0': 0.5, '3': 0.5}

The ideal simulator typically completes in seconds. Histogram keys are integer representations of basis states: state 0 = |00>, state 3 = |11> (binary 11 = 3). The simulator returns exact probabilities, not sampled counts. Convert to shot counts:

# Requires: ionq
histogram = result["data"]["histogram"]
shots = 1024
counts = {
    format(int(s), f'0{bell_circuit["qubits"]}b'): round(p * shots)
    for s, p in histogram.items()
}
print(counts)
# {'00': 512, '11': 512}

For hardware runs, the histogram contains actual measured frequencies rather than exact probabilities. You will see statistical fluctuations: instead of exactly 0.5 and 0.5, you might see 0.49 and 0.51, plus small probabilities for error states like |01> and |10>.

All-to-All Connectivity Advantage

One of the most significant practical differences between IonQ’s trapped-ion architecture and superconducting processors is connectivity. IonQ qubits have all-to-all connectivity: any two qubits can interact directly through the shared phonon bus. Superconducting processors (IBM, Google, Rigetti) use fixed coupling maps where each qubit connects to only 2-4 neighbors.

When a circuit requires a two-qubit gate between non-adjacent qubits on a superconducting chip, the compiler inserts SWAP gates to move the qubit states into adjacent positions. Each SWAP gate decomposes into 3 CNOT gates, each of which has an error rate of 0.3-1%. This routing overhead increases both circuit depth and total error.

Consider a fully connected 4-qubit problem, like simulating a 4-spin Ising model where every spin interacts with every other spin. This requires 6 pairwise interactions (the number of edges in a complete graph on 4 vertices).

On IonQ, each interaction is a single MS gate. Total two-qubit gates: 6.

On a superconducting processor with linear connectivity (qubit 0 connects to 1, 1 to 2, 2 to 3), some pairs like (0,3) and (0,2) are not adjacent. The compiler must insert SWAP gates. A conservative estimate for the linear topology:

  • Adjacent pairs (0,1), (1,2), (2,3): 3 MS/CX gates, no SWAPs needed
  • Non-adjacent pairs (0,2), (0,3), (1,3): each needs 1-2 SWAPs = 3-6 extra CX gates

Total two-qubit gates on linear topology: roughly 12-18 CX gates versus 6 MS gates on IonQ. At typical error rates, this makes the IonQ circuit 2-3x more accurate for this type of problem.

# 4-qubit fully connected Ising interactions on IonQ: 6 MS gates, no SWAPs
ising_4q = {
    "qubits": 4,
    "circuit": [
        # Prepare initial superposition on all qubits
        {"gate": "gpi2", "target": 0, "phase": 0.0},
        {"gate": "gpi2", "target": 1, "phase": 0.0},
        {"gate": "gpi2", "target": 2, "phase": 0.0},
        {"gate": "gpi2", "target": 3, "phase": 0.0},

        # All 6 pairwise interactions, each a single MS gate
        {"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}},
        {"gate": "ms", "targets": [0, 2], "phases": {"control": 0.0, "target": 0.0}},
        {"gate": "ms", "targets": [0, 3], "phases": {"control": 0.0, "target": 0.0}},
        {"gate": "ms", "targets": [1, 2], "phases": {"control": 0.0, "target": 0.0}},
        {"gate": "ms", "targets": [1, 3], "phases": {"control": 0.0, "target": 0.0}},
        {"gate": "ms", "targets": [2, 3], "phases": {"control": 0.0, "target": 0.0}},
    ]
}

This advantage grows with qubit count. For a fully connected N-qubit problem, the number of required two-qubit gates scales as N*(N-1)/2 on IonQ and significantly worse on linearly connected hardware.

Gate Fidelity and Hardware Specifications

Understanding hardware error rates helps you predict circuit performance and decide whether a given circuit is feasible.

IonQ Aria (25 qubits):

MetricValue
Single-qubit gate fidelity99.97% (error rate ~0.03%)
Two-qubit MS gate fidelity99.5% (error rate ~0.5%)
SPAM error (state preparation and measurement)~0.1%
T1 (energy relaxation time)> 1000 seconds
T2* (dephasing time)> 10 seconds
Quantum Volume>= 2^25 (33 million)

IonQ Forte (36 qubits):

MetricValue
Qubits36
Two-qubit MS gate fidelity99.8% (error rate ~0.2%)
Quantum Volume>= 2^35

Comparison with superconducting hardware:

MetricIonQ AriaIonQ ForteIBM Eagle (127q)
Two-qubit gate error~0.5%~0.2%~0.3-1%
ConnectivityAll-to-allAll-to-allHeavy-hex (nearest-neighbor)
Coherence timeSecondsSeconds~100 microseconds
Gate speed~100-500 us~100-500 us~100-500 ns

The trapped-ion advantage shows up in two-qubit gate fidelity and connectivity. The superconducting advantage shows up in gate speed. For circuits with moderate depth and high connectivity requirements, trapped ions tend to produce better results. For very deep circuits with limited connectivity, superconducting processors may be faster despite lower per-gate fidelity.

Quantum Volume and Benchmarking

Quantum Volume (QV) is a single-number benchmark that measures the effective computational power of a quantum processor. It accounts for gate fidelity, connectivity, and compiler quality. A higher QV means the processor can reliably execute wider and deeper circuits.

QV is defined as 2^n, where n is the largest circuit width (and depth) for which the heavy output generation probability exceeds 2/3. IonQ Aria achieves QV >= 2^25, and IonQ Forte achieves QV >= 2^35. These are among the highest published QV numbers in the industry.

IonQ also publishes a metric called Algorithmic Qubits (AQ), which counts the number of “useful” qubits after accounting for errors. AQ correlates with the largest problem size a processor can solve with reasonable accuracy.

You can run a simple benchmarking experiment on the noise model simulator to see how errors accumulate:

import ionq

client = ionq.Client()

def benchmark_circuit(n_qubits, depth):
    """Build a random-ish benchmark circuit with alternating layers."""
    gates = []
    for layer in range(depth):
        # Single-qubit layer: GPi2 on every qubit
        for q in range(n_qubits):
            gates.append({"gate": "gpi2", "target": q, "phase": 0.1 * layer})
        # Two-qubit layer: MS gates on adjacent pairs
        for q in range(0, n_qubits - 1, 2):
            offset = layer % 2  # stagger pairing each layer
            q0 = q + offset
            q1 = q0 + 1
            if q1 < n_qubits:
                gates.append({
                    "gate": "ms",
                    "targets": [q0, q1],
                    "phases": {"control": 0.0, "target": 0.0}
                })
    return {"qubits": n_qubits, "circuit": gates}

# Run 4-qubit, depth-4 benchmark on ideal and noisy simulators
bench = benchmark_circuit(4, 4)

ideal_job = client.create_job(circuit=bench, target="simulator", shots=1024)
noisy_job = client.create_job(
    circuit=bench,
    target="simulator.noise-model-aria-1",
    shots=1024,
)

ideal_result = client.get_job(ideal_job["id"])
noisy_result = client.get_job(noisy_job["id"])

# Compare distributions
print("Ideal:", ideal_result["data"]["histogram"])
print("Noisy:", noisy_result["data"]["histogram"])

The difference between the ideal and noisy histograms tells you how much the hardware errors affect this particular circuit. As depth increases, the noisy histogram becomes more uniform (approaching random noise), while the ideal histogram retains its structure.

Submitting to Real Hardware

Hardware jobs queue behind other users. Poll for completion rather than blocking indefinitely:

# Requires: ionq
import time

hardware_job = client.create_job(
    circuit=bell_circuit,
    target="qpu.aria-1",   # or "qpu.forte-1" for 36 qubits
    shots=100,
)

job_id = hardware_job["id"]
while True:
    status = client.get_job(job_id)["status"]
    print(status)
    if status in ("completed", "failed", "canceled"):
        break
    time.sleep(15)

result = client.get_job(job_id)
print(result["data"]["histogram"])

Robust Job Polling with Error Handling

The simple polling loop above works but lacks timeout handling and backoff. For production use, implement a more robust polling function:

import ionq
import time

client = ionq.Client()

def poll_job(client, job_id, timeout=600, initial_delay=10, max_delay=60):
    """Poll a job with exponential backoff and timeout.

    Args:
        client: ionq.Client instance
        job_id: ID of the submitted job
        timeout: Maximum seconds to wait before raising an error
        initial_delay: Seconds between first polls
        max_delay: Maximum seconds between polls (backoff cap)

    Returns:
        Full job result dict

    Raises:
        TimeoutError: If the job does not complete within the timeout
        RuntimeError: If the job fails or is canceled
    """
    start_time = time.time()
    delay = initial_delay

    while True:
        elapsed = time.time() - start_time
        if elapsed > timeout:
            raise TimeoutError(
                f"Job {job_id} did not complete within {timeout} seconds. "
                f"Last status: {status}"
            )

        result = client.get_job(job_id)
        status = result["status"]
        print(f"[{elapsed:.0f}s] Job {job_id}: {status}")

        if status == "completed":
            return result
        elif status == "failed":
            error_msg = result.get("error", {}).get("message", "Unknown error")
            raise RuntimeError(f"Job {job_id} failed: {error_msg}")
        elif status == "canceled":
            raise RuntimeError(f"Job {job_id} was canceled")

        # Exponential backoff: double the delay each time, up to max_delay
        time.sleep(delay)
        delay = min(delay * 2, max_delay)

Jobs pass through these states in order: ready, submitted, running, completed (or failed/canceled). You can also inspect job metadata after completion:

result = poll_job(client, job_id)

# Inspect metadata
print(f"Created:   {result.get('created_at')}")
print(f"Started:   {result.get('started_at')}")
print(f"Completed: {result.get('completed_at')}")
print(f"Backend:   {result.get('target')}")
print(f"Shots:     {result.get('shots')}")
print(f"Status:    {result.get('status')}")
print(f"Cost:      {result.get('cost')} credits")

Using the Noise Model Simulator

The noise model simulator applies the error characteristics of a specific QPU. Use it to estimate circuit performance before spending hardware budget.

# Requires: ionq
noisy_job = client.create_job(
    circuit=bell_circuit,
    target="simulator.noise-model-aria-1",
    shots=1024,
)
result = client.get_job(noisy_job["id"])
print(result["data"]["histogram"])
# Small non-zero probabilities for |01> and |10> from simulated gate errors

The noise model simulator is your best pre-hardware check. Before submitting a circuit to real hardware (and spending credits), run it on the noise model simulator first. If the noisy simulation produces garbage output, the hardware run will too.

Circuit Validation Before Submission

Submitting an invalid circuit to hardware wastes queue time and can consume credits on a failed job. Validate your circuit locally before submitting:

def validate_circuit(circuit, max_qubits=25):
    """Validate an IonQ native gate circuit before submission.

    Args:
        circuit: Circuit dict with "qubits" and "circuit" keys
        max_qubits: Maximum qubit count for the target device

    Returns:
        List of error strings (empty list if valid)
    """
    errors = []

    if "qubits" not in circuit:
        errors.append("Circuit missing 'qubits' key")
        return errors
    if "circuit" not in circuit:
        errors.append("Circuit missing 'circuit' key")
        return errors

    n_qubits = circuit["qubits"]
    if n_qubits > max_qubits:
        errors.append(
            f"Circuit uses {n_qubits} qubits, but target supports max {max_qubits}"
        )

    valid_gates = {"gpi", "gpi2", "ms"}

    for i, gate in enumerate(circuit["circuit"]):
        gate_name = gate.get("gate")

        if gate_name not in valid_gates:
            errors.append(f"Gate {i}: unknown gate '{gate_name}' (valid: {valid_gates})")
            continue

        if gate_name in ("gpi", "gpi2"):
            # Single-qubit gate checks
            if "target" not in gate:
                errors.append(f"Gate {i}: {gate_name} missing 'target'")
            elif gate["target"] >= n_qubits or gate["target"] < 0:
                errors.append(
                    f"Gate {i}: target {gate['target']} out of range [0, {n_qubits - 1}]"
                )
            if "phase" not in gate:
                errors.append(f"Gate {i}: {gate_name} missing 'phase'")
            elif not (0.0 <= gate["phase"] < 1.0):
                errors.append(
                    f"Gate {i}: phase {gate['phase']} outside [0, 1) turns"
                )

        elif gate_name == "ms":
            # Two-qubit gate checks
            if "targets" not in gate:
                errors.append(f"Gate {i}: ms missing 'targets'")
            elif len(gate["targets"]) != 2:
                errors.append(f"Gate {i}: ms 'targets' must have exactly 2 elements")
            else:
                for t in gate["targets"]:
                    if t >= n_qubits or t < 0:
                        errors.append(
                            f"Gate {i}: target {t} out of range [0, {n_qubits - 1}]"
                        )
                if gate["targets"][0] == gate["targets"][1]:
                    errors.append(f"Gate {i}: ms targets must be different qubits")

            if "phases" not in gate:
                errors.append(f"Gate {i}: ms missing 'phases' (note: plural, not 'phase')")
            elif not isinstance(gate["phases"], dict):
                errors.append(f"Gate {i}: ms 'phases' must be a dict with 'control' and 'target' keys")
            else:
                for key in ("control", "target"):
                    if key not in gate["phases"]:
                        errors.append(f"Gate {i}: ms 'phases' missing '{key}' key")

    return errors

# Usage
errors = validate_circuit(bell_circuit)
if errors:
    print("Validation errors:")
    for e in errors:
        print(f"  - {e}")
else:
    print("Circuit is valid")

A good workflow is: validate locally, then run on the ideal simulator, then run on the noise model simulator, and only then submit to hardware.

Cost and Pricing

IonQ charges per gate, not per shot. This means running 100 shots and 10,000 shots of the same circuit costs the same amount. The cost depends on the number and type of gates in the circuit.

Approximate per-gate costs on Aria (as of early 2026):

Gate typeApproximate cost per gate
Single-qubit (GPi, GPi2)$0.00035
Two-qubit (MS)$0.00097

For the Bell state circuit (1 GPi2 + 1 MS):

  • 1 x 0.00035=0.00035 = 0.00035 (GPi2)
  • 1 x 0.00097=0.00097 = 0.00097 (MS)
  • Total: $0.00132 per job, regardless of shot count

For the 3-qubit GHZ circuit (4 single-qubit gates + 2 MS gates):

  • 4 x 0.00035=0.00035 = 0.00140 (single-qubit gates)
  • 2 x 0.00097=0.00097 = 0.00194 (MS gates)
  • Total: $0.00334 per job

You can check recent job costs by listing your jobs:

import ionq

client = ionq.Client()

# List recent jobs with their costs
recent_jobs = client.get_jobs(params={"limit": 5})
for job in recent_jobs:
    print(
        f"Job {job['id']}: "
        f"status={job['status']}, "
        f"target={job.get('target', 'N/A')}, "
        f"cost={job.get('cost', 'N/A')} credits"
    )

To estimate cost before submitting, count the gates:

def estimate_cost(circuit, single_qubit_cost=0.00035, ms_cost=0.00097):
    """Estimate the cost of a circuit in USD."""
    total = 0.0
    for gate in circuit["circuit"]:
        if gate["gate"] in ("gpi", "gpi2"):
            total += single_qubit_cost
        elif gate["gate"] == "ms":
            total += ms_cost
    return total

cost = estimate_cost(bell_circuit)
print(f"Estimated cost: ${cost:.5f}")
# Estimated cost: $0.00132

Batch Submission

IonQ supports submitting multiple circuits in a single API call. This is more efficient than individual submissions because it avoids re-entering the queue between circuits:

import ionq

client = ionq.Client()

# Define multiple circuits
circuits = []
for phase in [0.0, 0.1, 0.2, 0.3, 0.4]:
    circuits.append({
        "qubits": 2,
        "circuit": [
            {"gate": "gpi2", "target": 0, "phase": phase},
            {"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}},
        ]
    })

# Submit as a batch
batch_job = client.create_job(
    circuit=circuits,
    target="simulator",
    shots=1024,
)

# Retrieve results
result = client.get_job(batch_job["id"])

# Each circuit's result is indexed in the response
for i, circuit_result in enumerate(result["data"]["children"]):
    print(f"Circuit {i} (phase={i * 0.1:.1f}): {circuit_result['histogram']}")

Batch submission is especially useful for parameter sweeps, where you want to run the same circuit structure with varying gate angles. It reduces total queue wait time compared to submitting each circuit individually.

Available Targets

TargetTypeQubits
simulatorIdeal, no noiseup to 29
simulator.noise-model-aria-1Aria noise model25
qpu.aria-1IonQ Aria hardware25
qpu.aria-2IonQ Aria hardware25
qpu.forte-1IonQ Forte hardware36

Always check target availability before submitting to hardware:

import ionq

client = ionq.Client()

targets = client.get_targets()
for t in targets:
    print(f"{t['name']}: status={t.get('status', 'N/A')}, qubits={t.get('qubits', 'N/A')}")

Hardware targets periodically go offline for calibration and maintenance. If qpu.aria-1 is offline, try qpu.aria-2 (same hardware generation, same gate set and qubit count).

Common Mistakes

These are the errors that trip up most new users of the IonQ native SDK.

1. Using radians instead of turns

This is the most common mistake by far. If you have a phase angle in radians (for example, pi/2 = 1.5708) and pass it directly to the IonQ SDK, the gate interprets it as 1.5708 turns = 565.5 degrees. The result is a completely wrong rotation.

import numpy as np

# WRONG: passing radians directly
bad_gate = {"gate": "gpi2", "target": 0, "phase": np.pi / 2}  # 1.5708 turns!

# CORRECT: convert radians to turns first
phase_radians = np.pi / 2
phase_turns = phase_radians / (2 * np.pi)  # = 0.25 turns
good_gate = {"gate": "gpi2", "target": 0, "phase": phase_turns}

Rule of thumb: if your phase value is greater than 1.0, you almost certainly forgot to convert from radians.

2. Using “phase” instead of “phases” for the MS gate

Single-qubit gates use "phase" (singular). The MS gate uses "phases" (plural) with a dict value. Mixing these up causes an API error or silently incorrect behavior.

# WRONG: singular "phase" on MS gate
{"gate": "ms", "targets": [0, 1], "phase": 0.0}

# CORRECT: plural "phases" with control/target keys
{"gate": "ms", "targets": [0, 1], "phases": {"control": 0.0, "target": 0.0}}

3. Polling too frequently

The IonQ API rate-limits requests. If you poll every second, you will hit rate limits and get HTTP 429 errors. Use a minimum delay of 10 seconds between polls, and preferably implement exponential backoff (see the robust polling section above).

# WRONG: polling every second
while True:
    status = client.get_job(job_id)["status"]
    time.sleep(1)  # Too fast, will hit rate limits

# CORRECT: poll every 15 seconds or use exponential backoff
while True:
    status = client.get_job(job_id)["status"]
    time.sleep(15)

4. Treating simulator probabilities as counts

The ideal simulator returns exact probabilities (values that sum to 1.0), not shot counts. If your analysis code expects integer counts, you need to multiply by the shot count:

histogram = result["data"]["histogram"]
shots = 1024

# WRONG: treating probabilities as counts
# histogram = {'0': 0.5, '3': 0.5}
# These are NOT counts of 0.5

# CORRECT: convert to counts
counts = {state: round(prob * shots) for state, prob in histogram.items()}
# counts = {'0': 512, '3': 512}

Hardware results also return probabilities (measured frequencies normalized to sum to 1.0), so this conversion is needed in both cases.

5. Submitting to offline hardware

Hardware targets go offline for calibration. If you submit to qpu.aria-1 while it is in maintenance, your job may sit in the queue indefinitely or fail.

import ionq

client = ionq.Client()

# Check before submitting
targets = client.get_targets()
aria_targets = [t for t in targets if "aria" in t["name"]]
for t in aria_targets:
    print(f"{t['name']}: {t.get('status', 'unknown')}")

# Only submit if the target is available
target_name = "qpu.aria-1"
available = any(
    t["name"] == target_name and t.get("status") == "available"
    for t in targets
)
if available:
    job = client.create_job(circuit=bell_circuit, target=target_name, shots=100)
else:
    print(f"{target_name} is not available. Try another target or use the simulator.")

Comparison: When to Use Each IonQ Access Path

OptionBest for
ionq SDK (native gates)Maximum control, benchmarking, production fidelity
cirq-ionqTeams using Cirq who want IonQ as a backend
qiskit-ionqTeams using Qiskit who want IonQ as a backend
Amazon BraketAWS-centric teams, unified billing across providers
Azure QuantumMicrosoft-centric teams, Azure credits
SuperstaqCross-platform compilation with hardware-aware optimization

The native SDK gives the closest view of what the hardware does. For exploratory work, Qiskit or Cirq integrations are more convenient. For production circuits where gate count and fidelity matter, native gates are worth the additional setup.

The key tradeoff: higher-level SDKs (Qiskit, Cirq, Braket) let you write circuits in standard gates (H, CNOT, RZ) and automatically compile them to native gates. This is convenient but adds compiler overhead. The native SDK forces you to think in native gates from the start, giving you full control over the exact operations executed on hardware.

What to Try Next

  • Compare the results of the ideal simulator versus the noise model simulator on the GHZ circuit to see how errors manifest in multi-qubit entanglement
  • Build a 4-qubit GHZ state by extending the pattern from the 3-qubit version
  • Implement a simple variational circuit with parameterized GPi2 phases and optimize them classically
  • Try accessing IonQ via cirq-ionq to see how Cirq’s H and CNOT get translated to native gates automatically
  • Run the same circuit on both simulator.noise-model-aria-1 and qpu.aria-1 to compare simulated noise with real hardware noise
  • Read the IonQ SDK Reference for the full gate set and API details
  • Look at Superstaq if you want a compiler that targets IonQ alongside other backends from a single circuit

Was this tutorial helpful?