Cirq Intermediate Free 7/13 in series 13 min read

Advanced Gates and Custom Unitaries in Cirq

Define custom quantum gates in Cirq from unitary matrices, use the fermionic simulation gate (FSimGate), and build parameterized custom operations.

What you'll learn

  • cirq
  • custom gates
  • unitary
  • fermionic simulation
  • iSwap

Prerequisites

  • Python proficiency
  • Beginner quantum computing concepts (superposition, entanglement)
  • Linear algebra basics

Cirq gives you fine-grained control over quantum gates that higher-level frameworks abstract away. You can define gates from raw unitary matrices, express parameterized families of gates with symbolic expressions, and use Google’s native two-qubit gate (FSimGate) directly. This tutorial covers the mechanics of custom gate definition, decomposition, and simulation.

Installation

pip install cirq cirq-core sympy

Gate Abstractions in Cirq

Cirq separates three concepts that other frameworks conflate:

  • Gate: a reusable quantum operation defined by its mathematical action (a unitary, a channel, a measurement). Gates are stateless; they carry no qubit information.
  • Operation: a Gate applied to specific qubits. cirq.H(q) is an Operation.
  • Moment: a set of Operations that can be applied simultaneously (no shared qubits). A list of Moments forms a Circuit.

This distinction makes it straightforward to define a gate once and apply it to arbitrary qubits, or to inspect a circuit’s time structure explicitly.

MatrixGate: Any Unitary from a Matrix

cirq.MatrixGate wraps any unitary matrix as a gate. This is the fastest path to using an arbitrary single- or two-qubit operation:

import cirq
import numpy as np

q = cirq.LineQubit.range(2)

# Custom single-qubit phase gate: |0> -> |0>, |1> -> e^(i*phi)|1>
phi = np.pi / 4
phase_matrix = np.array([
    [1, 0],
    [0, np.exp(1j * phi)]
])
phase_gate = cirq.MatrixGate(phase_matrix)
print("Phase gate unitary:\n", cirq.unitary(phase_gate))

# Verify it is unitary
U = cirq.unitary(phase_gate)
assert np.allclose(U @ U.conj().T, np.eye(2)), "Not unitary"

# Apply it in a circuit
circuit = cirq.Circuit([
    cirq.H(q[0]),
    phase_gate(q[0]),
    cirq.CNOT(q[0], q[1]),
])
print(circuit)

# Custom two-qubit SWAP-like gate: parametric SWAP at angle pi/4
theta = np.pi / 4
swap_matrix = np.array([
    [1, 0,                     0,                     0],
    [0, np.cos(theta),         1j * np.sin(theta),    0],
    [0, 1j * np.sin(theta),    np.cos(theta),          0],
    [0, 0,                     0,                     1],
])
partial_swap = cirq.MatrixGate(swap_matrix)

circuit2 = cirq.Circuit([partial_swap(q[0], q[1])])
print(circuit2)
print("Partial SWAP unitary:\n", np.round(cirq.unitary(partial_swap), 3))

MatrixGate automatically generates circuit diagram symbols and handles simulation. The downside: it cannot be decomposed into native gates automatically; use cirq.decompose for that.

Parameterized Gates with Sympy

Sympy symbols let you define gate families where parameters are resolved later, useful for variational algorithms and hardware calibration:

import cirq
import sympy
import numpy as np

# Define a symbolic rotation angle
theta = sympy.Symbol("theta")
phi   = sympy.Symbol("phi")

# Cirq's built-in parameterized Rz gate
q = cirq.LineQubit(0)
rz = cirq.rz(theta)(q)

circuit = cirq.Circuit([
    cirq.H(q),
    cirq.rz(theta)(q),
    cirq.ry(phi)(q),
])

print("Parameterized circuit:")
print(circuit)

# Resolve symbols to concrete values
resolver = cirq.ParamResolver({"theta": np.pi / 3, "phi": np.pi / 6})
resolved = cirq.resolve_parameters(circuit, resolver)
print("\nResolved circuit:")
print(resolved)

# Simulate the resolved circuit
sim = cirq.Simulator()
result = sim.simulate(resolved)
print("\nFinal state vector:", result.final_state_vector)

Parameterized circuits in Cirq are compatible with cirq.sweep: running the same circuit over a grid of parameter values in one call, which is efficient for variational sweeps.

FSimGate: Google’s Native Two-Qubit Gate

The FSimGate(theta, phi) is Google’s hardware-native two-qubit gate on Sycamore processors. It combines a partial SWAP (fermionic SWAP) with a conditional phase:

FSimGate(theta, phi) matrix (in the |00>, |01>, |10>, |11> basis):
[1,               0,               0,    0           ]
[0,   cos(theta),  -i*sin(theta),    0           ]
[0,  -i*sin(theta),   cos(theta),    0           ]
[0,               0,               0,  e^(-i*phi) ]

Special cases:

  • FSimGate(-pi/2, 0) = iSWAP
  • FSimGate(pi/4, 0) = sqrt(iSWAP)
  • FSimGate(0, pi) = CZ
  • FSimGate(pi/2, pi/6) = Sycamore gate
import cirq
import numpy as np

q0, q1 = cirq.LineQubit.range(2)

# iSWAP-equivalent
iswap_fsim = cirq.FSimGate(theta=np.pi/2, phi=0)
print("FSimGate(pi/2, 0) unitary:")
print(np.round(cirq.unitary(iswap_fsim), 3))

# Compare to cirq.ISWAP directly
print("\ncirq.ISWAP unitary:")
print(np.round(cirq.unitary(cirq.ISWAP), 3))

# CZ-equivalent
cz_fsim = cirq.FSimGate(theta=0, phi=np.pi)
print("\nFSimGate(0, pi) unitary (should match CZ):")
print(np.round(cirq.unitary(cz_fsim), 3))

# Sycamore gate: the actual Google hardware gate
sycamore_gate = cirq.FSimGate(theta=np.pi/2, phi=np.pi/6)

circuit = cirq.Circuit([
    cirq.H(q0),
    sycamore_gate(q0, q1),
    cirq.H(q1),
])
print("\nSycamore gate circuit:")
print(circuit)

When targeting Google hardware, building circuits from FSimGate operations eliminates the translation pass entirely and reduces gate count.

Custom Gate Class

For gates you intend to reuse widely, define a proper cirq.Gate subclass. This gives you control over diagram labels, decomposition, and serialization:

import cirq
import numpy as np
from typing import Sequence, Union

class ControlledPhaseGate(cirq.Gate):
    """
    A controlled-phase gate: applies phase e^(i*phi) to |11>.
    This is equivalent to cirq.CZPowGate but written from scratch.
    """

    def __init__(self, phi: float):
        self.phi = phi

    def _num_qubits_(self) -> int:
        return 2

    def _unitary_(self) -> np.ndarray:
        return np.array([
            [1, 0, 0, 0],
            [0, 1, 0, 0],
            [0, 0, 1, 0],
            [0, 0, 0, np.exp(1j * self.phi)],
        ])

    def _circuit_diagram_info_(
        self, args: cirq.CircuitDiagramInfoArgs
    ) -> cirq.CircuitDiagramInfo:
        angle_str = f"{self.phi / np.pi:.2f}π"
        return cirq.CircuitDiagramInfo(wire_symbols=["@", f"P({angle_str})"])

    def __repr__(self) -> str:
        return f"ControlledPhaseGate(phi={self.phi})"

# Use it in a circuit
q0, q1 = cirq.LineQubit.range(2)
cp_gate = ControlledPhaseGate(phi=np.pi / 3)

circuit = cirq.Circuit([
    cirq.H(q0),
    cirq.H(q1),
    cp_gate(q0, q1),
])
print(circuit)
print("Unitary:\n", np.round(cirq.unitary(cp_gate), 3))

Gate Decomposition

Add a _decompose_ method to express your gate in terms of standard gates. Cirq uses this for compilation and for hardware targets that don’t natively support your gate:

import cirq
import numpy as np

class DecomposableControlledPhase(cirq.Gate):
    def __init__(self, phi: float):
        self.phi = phi

    def _num_qubits_(self) -> int:
        return 2

    def _unitary_(self) -> np.ndarray:
        return np.diag([1, 1, 1, np.exp(1j * self.phi)])

    def _decompose_(self, qubits):
        q0, q1 = qubits
        # Controlled-phase decomposes as:
        # Rz(phi/2) on q0, Rz(phi/2) on q1, CX, Rz(-phi/2) on q1, CX
        yield cirq.rz(self.phi / 2)(q0)
        yield cirq.rz(self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)
        yield cirq.rz(-self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)

    def _circuit_diagram_info_(self, args):
        return cirq.CircuitDiagramInfo(wire_symbols=["@", f"P({self.phi:.2f})"])

q0, q1 = cirq.LineQubit.range(2)
gate = DecomposableControlledPhase(phi=np.pi / 4)

# Original circuit
circuit = cirq.Circuit([gate(q0, q1)])
print("Original circuit:")
print(circuit)

# Decomposed form
decomposed = cirq.Circuit(cirq.decompose(gate(q0, q1)))
print("\nDecomposed circuit:")
print(decomposed)

# Verify unitaries match up to global phase (global phase is physically unobservable)
U1 = cirq.unitary(circuit)
U2 = cirq.unitary(decomposed)
idx = np.argmax(np.abs(U2.flat))
phase = U1.flat[idx] / U2.flat[idx]
assert np.allclose(U1, phase * U2), "Unitaries do not match even up to global phase"
print("Unitaries match (up to global phase): True")

Simulating Custom Gates

Custom gates work with all Cirq simulators without any extra configuration:

import cirq
import numpy as np

class DecomposableControlledPhase(cirq.Gate):
    def __init__(self, phi: float):
        self.phi = phi

    def _num_qubits_(self) -> int:
        return 2

    def _unitary_(self) -> np.ndarray:
        return np.diag([1, 1, 1, np.exp(1j * self.phi)])

    def _decompose_(self, qubits):
        q0, q1 = qubits
        yield cirq.rz(self.phi / 2)(q0)
        yield cirq.rz(self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)
        yield cirq.rz(-self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)

    def _circuit_diagram_info_(self, args):
        return cirq.CircuitDiagramInfo(wire_symbols=["@", f"P({self.phi:.2f})"])

q0, q1 = cirq.LineQubit.range(2)

gate = DecomposableControlledPhase(phi=np.pi)   # phi=pi is a CZ gate

circuit = cirq.Circuit([
    cirq.H(q0),
    cirq.H(q1),
    gate(q0, q1),
    cirq.H(q0),
    cirq.H(q1),
])

sim = cirq.Simulator()
result = sim.simulate(circuit)
print("Final state vector:")
print(np.round(result.final_state_vector, 3))
print("\nCircuit:")
print(circuit)

# Measure
circuit_with_measurement = circuit + cirq.measure(q0, q1, key="result")
sample_result = sim.run(circuit_with_measurement, repetitions=1000)
print("\nMeasurement results:")
print(sample_result.histogram(key="result"))

Exporting to OpenQASM 3

Cirq can serialize circuits with custom gates to OpenQASM 3 for interoperability with other frameworks, provided the gate has a decomposition into standard gates:

import cirq
import numpy as np

class DecomposableControlledPhase(cirq.Gate):
    def __init__(self, phi: float):
        self.phi = phi

    def _num_qubits_(self) -> int:
        return 2

    def _unitary_(self) -> np.ndarray:
        return np.diag([1, 1, 1, np.exp(1j * self.phi)])

    def _decompose_(self, qubits):
        q0, q1 = qubits
        yield cirq.rz(self.phi / 2)(q0)
        yield cirq.rz(self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)
        yield cirq.rz(-self.phi / 2)(q1)
        yield cirq.CNOT(q0, q1)

    def _circuit_diagram_info_(self, args):
        return cirq.CircuitDiagramInfo(wire_symbols=["@", f"P({self.phi:.2f})"])

q0, q1 = cirq.LineQubit.range(2)
gate = DecomposableControlledPhase(phi=np.pi / 2)
circuit = cirq.Circuit([
    cirq.H(q0),
    gate(q0, q1),
    cirq.measure(q0, q1, key="out"),
])

# Decompose custom gates before export
decomposed_circuit = cirq.Circuit(cirq.decompose(circuit))
try:
    qasm_output = cirq.qasm(decomposed_circuit)
    print("OpenQASM 3 output:")
    print(qasm_output)
except Exception as e:
    print(f"QASM export note: {e}")
    print("Full decomposed circuit:")
    print(decomposed_circuit)

If export fails for gates without built-in QASM support, decompose first then export the decomposed circuit, which consists only of standard gates.

What to Try Next

  • Use cirq.testing.assert_implements_consistent_protocols(gate) to validate that your custom gate satisfies all Cirq gate contracts
  • Implement a parameterized FSimGate sweep to characterize how entanglement varies with theta from 0 to pi/2
  • Use cirq.google.optimized_for_sycamore() to compile arbitrary circuits to Sycamore-native FSimGate operations
  • Read the Cirq custom gates documentation for the full protocol interface

Was this tutorial helpful?