15 rules.
11 for Python files and notebooks, and 4 for circuits in memory, 2 of them in preview.
It reads your code. It never runs it.
qxlint finds the mistakes the move from V1 to V2 primitives introduced: counts read off the wrong object, a V1 field on a V2 result, a circuit sampled without a measurement, and a channel a release has removed.
uvx qxlint .
Python 3.11 to 3.14 · Qiskit optional · Free, MIT License
Two things fail quietly in Primitives V2 code. Calling get_counts() one level too high raises, but only after the job has run. And a circuit with no measurement instruction can come back as all zeros, which looks like a physics result. Both can be decided from the source, without a model, a network, or a quantum computer.
11 for Python files and notebooks, and 4 for circuits in memory, 2 of them in preview.
It never imports or executes your code, makes no network requests, and needs no quantum hardware.
Run in CI on Python 3.11, 3.12, 3.13 and 3.14, with Qiskit, without it, and at its lowest version.
Of statements and branches, held by a CI gate rather than reported as a number.
Its model of Qiskit, checked against a real install on the 1st and 15th of every month.
Rules
Every rule page documents when the pattern it catches is legitimate. If that cannot be written, the rule does not ship. Pick a rule to see what it flags, what it leaves alone, and what qxlint printed for each.
QXL0 · Parsing
QXL1 · Results and circuits
QXL2 · Runtime and API
QXL3 · Circuits in memory
QXL000
The file is not valid Python for the interpreter running qxlint, or the notebook is not valid nbformat JSON. Nothing downstream can run. An interpreter cannot parse syntax newer than itself, so code using 3.14 only syntax needs qxlint to run on 3.14.
Never as a finding to ignore, though a file that is intentionally not Python should be excluded rather than suppressed.
def f($ qxlint example.pyexample.py:1:6: QXL000 cannot parse: '(' was never closed
def f():
pass$ qxlint example.pyNo findings
QXL101
get_counts() called on a container that does not have itIn the Primitives V2 result shape, counts live on a BitArray, not on the result, the pub result, or the data bin. Calling get_counts() on any of those raises AttributeError at runtime. The shape is PrimitiveResult → PubResult → .data (DataBin) → <classical register> (BitArray). The register is named by the circuit, so it is not always meas.
Never on these receivers. get_counts() is correct and common on a BitArray, and on a legacy backend.run(...).result(), and this rule fires on neither. It also stays silent whenever the receiver type cannot be proven.
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.measure_all()
result = StatevectorSampler().run([qc]).result()
counts = result.get_counts()$ qxlint example.pyexample.py:7:10: QXL101 get_counts() on a PrimitiveResult; counts live on the BitArray in PrimitiveResult -> PubResult -> .data (DataBin) -> <classical register> (BitArray)
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.measure_all()
result = StatevectorSampler().run([qc]).result()
counts = result[0].data.meas.get_counts()$ qxlint example.pyNo findings
QXL102
quasi_dists is a field of the V1 SamplerResult and values is a field of the V1 EstimatorResult. A V2 PrimitiveResult has neither and raises AttributeError, verified on Qiskit 2.5.2 for both primitives. V2 exposes counts through the data bin as result[i].data.<register>.get_counts(), and expectation values as result[i].data.evs.
Reading quasi_dists from a V1 qiskit.primitives.SamplerResult, or values from a V1 EstimatorResult, is still valid, and this rule does not fire there. The same holds for a Runtime V1 result: qiskit_ibm_runtime.Sampler was SamplerV1 until 0.28, so on a target proven to predate that release its result is not a V2 one and nothing is reported. It also stays silent on any object whose type cannot be proven, so a helper that returns an unannotated result is never flagged on the strength of the attribute name alone.
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.measure_all()
result = StatevectorSampler().run([qc]).result()
dists = result.quasi_dists$ qxlint example.pyexample.py:7:9: QXL102 quasi_dists does not exist on a V2 PrimitiveResult; read result[i].data.<register>.get_counts() instead
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.measure_all()
result = StatevectorSampler().run([qc]).result()
counts = result[0].data.meas.get_counts()$ qxlint example.pyNo findings
QXL103
A Sampler reports classical bits. A circuit with no measurement instruction produces nothing useful, and the failure is quiet. With no classical register, Qiskit 2.5.2 emits only a UserWarning and returns an empty data bin. With a classical register but no measure instruction, there is no warning at all and every shot reads as zeros, which looks like a physics result rather than a mistake.
Never for a Sampler, which is why the rule is limited to one. An unmeasured circuit is the correct and normal input to an Estimator, and this rule never looks at Estimators. It also requires proof: it fires only when the circuit was built and mutated in view, never reached unmodeled code, and has no measurement on any path.
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
StatevectorSampler().run([qc])$ qxlint example.pyexample.py:7:1: QXL103 circuit has no measurement instructions but is passed to a SamplerV2; the result carries no counts
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorSampler
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)
qc.measure_all()
StatevectorSampler().run([qc])$ qxlint example.pyNo findings
QXL104
Several QuantumCircuit methods return a new circuit rather than mutating the receiver, and compose, tensor and assign_parameters default to inplace=False. Written as a bare statement the call does nothing at all, and nothing reports it: verified on Qiskit 2.5.2, qc.compose(other) as a statement leaves qc with zero instructions, and qc.assign_parameters({t: 1.0}) leaves the parameter unbound. The circuit then runs, and the result is wrong rather than missing.
When the method really does mutate, which is why the rule reads the inplace argument instead of the method name alone: qc.compose(other, inplace=True) and qc.measure_all() are both correct and neither is flagged. It also only fires on a bare expression statement, so assigning or returning the result is never flagged, and only on a receiver proven to be a circuit.
from qiskit import QuantumCircuit
qc = QuantumCircuit(2)
other = QuantumCircuit(2)
other.h(0)
qc.compose(other)$ qxlint example.pyexample.py:6:1: QXL104 this statement does nothing: compose() defaults to inplace=False and the result is discarded
from qiskit import QuantumCircuit
qc = QuantumCircuit(2)
other = QuantumCircuit(2)
other.h(0)
qc = qc.compose(other)$ qxlint example.pyNo findings
QXL105
An Estimator computes expectation values from the statevector and has no use for classical bits. StatevectorEstimator refuses them outright: verified on Qiskit 2.5.2, it raises QiskitError('Cannot apply instruction with classical bits: measure'). This usually appears when a circuit written for a Sampler is reused for an Estimator without removing the measurements.
Never for StatevectorEstimator, which is the only implementation this rule looks at. Other estimators are excluded on purpose: what a Runtime EstimatorV2 does with a measured circuit is decided server side and cannot be established offline, so qxlint says nothing about it rather than guessing. BackendEstimatorV2 is excluded for the same reason.
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
qc = QuantumCircuit(2)
qc.h(0)
qc.measure_all()
StatevectorEstimator().run([(qc, SparsePauliOp('ZZ'))])$ qxlint example.pyexample.py:8:1: QXL105 circuit has measurements but is passed to a StatevectorEstimator, which raises on classical bits
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
qc = QuantumCircuit(2)
qc.h(0)
StatevectorEstimator().run([(qc, SparsePauliOp('ZZ'))])$ qxlint example.pyNo findings
QXL201
channel="ibm_quantum" is removed in qiskit-ibm-runtime 0.41The "ibm_quantum" channel was deprecated in qiskit-ibm-runtime 0.40.0 and removed in 0.41.0 with the sunset of IBM Quantum Platform Classic. Verified on qiskit-ibm-runtime 0.49.0: QiskitRuntimeService raises ValueError during construction, and QiskitRuntimeService.save_account raises InvalidAccountError, so both call sites are checked. Both "ibm_cloud" and "ibm_quantum_platform" point at the same API, "ibm_quantum_platform" is the default, and the argument can be omitted entirely.
On a target proven to predate qiskit-ibm-runtime 0.41 the value still works, and the rule downgrades to a deprecation notice from 0.40 and stays silent below it. A target that cannot be established is read as current, so the rule reports: a project that never states its version is far more likely to be running today's release than one from before 0.41, and staying silent there hid the finding from every project without a declared target.
from qiskit_ibm_runtime import QiskitRuntimeService
service = QiskitRuntimeService(channel="ibm_quantum", token=TOKEN)$ qxlint example.pyexample.py:3:40: QXL201 channel="ibm_quantum" was removed in qiskit-ibm-runtime 0.41; omit the channel argument
from qiskit_ibm_runtime import QiskitRuntimeService
service = QiskitRuntimeService(token=TOKEN)$ qxlint example.pyNo findings
QXL202
backend= or session= instead of mode=The V1 Runtime primitives took backend= and session=. The V2 primitives replaced both with a single mode=, which accepts a backend, a Session or a Batch. Passing the old name raises TypeError immediately: verified on qiskit-ibm-runtime 0.49.0, SamplerV2(backend=...) fails with unexpected keyword argument 'backend'. This is one of the most common leftovers of a V1 to V2 migration, because the call still reads as if it should work.
Never on a V2 Runtime primitive. It is very much legitimate on Session and Batch, which really do take backend=, and this rule never looks at those. It also stays silent unless the constructor is proven to be a Runtime SamplerV2 or EstimatorV2, so a local class of the same name is not flagged. The bare names Sampler and Estimator were the V1 classes until qiskit-ibm-runtime 0.28, so on a target proven to predate that release they take backend= and session= correctly and are not flagged. With no target declared the current package is assumed, where both names are the V2 classes.
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
service = QiskitRuntimeService()
backend = service.least_busy()
sampler = SamplerV2(backend=backend)$ qxlint example.pyexample.py:5:29: QXL202 SamplerV2 takes mode=, not backend=; the V2 primitives replaced both backend= and session= with mode=
from qiskit_ibm_runtime import QiskitRuntimeService, SamplerV2
service = QiskitRuntimeService()
backend = service.least_busy()
sampler = SamplerV2(mode=backend)$ qxlint example.pyNo findings
QXL203
service= passed to Session or Batch, which dropped the argumentSession and Batch took a service argument until qiskit-ibm-runtime 0.33.2 and dropped it in 0.34.0, read from the published wheels. Passing it now raises TypeError before anything reaches the service, so the whole script fails at that line. Every tutorial written before that release opens with this call.
On a target proven to predate 0.34.0 the argument is still accepted, and the rule stays silent there. It also only fires on a service keyword, so Session(backend=...) and Session(mode=...) are never flagged, and only on the two classes that changed, resolved through the import rather than by name, so a local class called Session is not touched.
from qiskit_ibm_runtime import QiskitRuntimeService, Session
service = QiskitRuntimeService()
session = Session(service=service, backend="ibm_brisbane")$ qxlint example.pyexample.py:4:27: QXL203 Session takes no service argument; it was removed in qiskit-ibm-runtime 0.34 and passing it raises TypeError
from qiskit_ibm_runtime import QiskitRuntimeService, Session
service = QiskitRuntimeService()
backend = service.backend("ibm_brisbane")
session = Session(backend=backend)$ qxlint example.pyNo findings
QXL204
run() called the way V1 took its argumentsThe V1 primitives took parallel lists: estimator.run(circuits, observables, parameter_values). Every V2 primitive takes one argument, a list of pubs, plus a keyword only shots for a sampler or precision for an estimator. Verified on Qiskit 2.5.2 and qiskit-ibm-runtime 0.49.0: a second positional argument, any of the three V1 keywords, and shots on an estimator each raise TypeError. Changing the import without changing the call is the defining break of Primitives V2.
Never on a proven V2 primitive, because the call cannot execute. The rule reads the receiver's kind rather than the method name, so a V1 primitive keeps its own grammar and is not flagged, and so is any object whose type could not be proven. shots is only wrong on an estimator, and is left alone on a sampler where it is the documented keyword.
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
qc = QuantumCircuit(2)
qc.h(0)
StatevectorEstimator().run([qc], [SparsePauliOp('ZZ')])$ qxlint example.pyexample.py:7:1: QXL204 run() on an EstimatorV2 takes one argument, a list of pubs; the V1 form took parallel lists, so this call raises TypeError
from qiskit import QuantumCircuit
from qiskit.primitives import StatevectorEstimator
from qiskit.quantum_info import SparsePauliOp
qc = QuantumCircuit(2)
qc.h(0)
StatevectorEstimator().run([(qc, SparsePauliOp('ZZ'))])$ qxlint example.pyNo findings
QXL205
Qiskit 1.0 removed the top level execute, Aer and IBMQ, the opflow and algorithms packages, the vendored providers.aer, and QuantumInstance. Qiskit 2.0 removed assemble and the V1 primitives Sampler, Estimator, BackendSampler and BackendEstimator. Each release was read from the published wheels and each absence confirmed on Qiskit 2.5.2. The import raises before a single line of the script runs, so nothing downstream can be reached. Two QuantumCircuit methods went the same way, bind_parameters and qasm, and are reported on the call rather than on an import.
On a target proven to predate the release that removed the name, the import still works and the rule stays silent. It reads the dotted path of the import rather than the bound name, so a local module of the same name is not touched, and a relative import, which names nothing outside the package, is never considered. A removed method is reported only on a receiver the analyzer has proved to be a QuantumCircuit, so a method of the same name on any other object is left alone.
from qiskit import QuantumCircuit, execute, Aer
qc = QuantumCircuit(2)
bound = qc.bind_parameters({})$ qxlint example.pyexample.py:1:1: QXL205 qiskit.execute was removed in Qiskit 1.0; this import raises before anything else runsexample.py:1:1: QXL205 qiskit.Aer was removed in Qiskit 1.0; this import raises before anything else runsexample.py:4:9: QXL205 QuantumCircuit.bind_parameters was removed in Qiskit 1.0; calling it raises AttributeError
from qiskit import QuantumCircuit, transpile
from qiskit_aer import AerSimulator
qc = QuantumCircuit(2)
bound = qc.assign_parameters({})$ qxlint example.pyNo findings
QXL300
The circuit rules descend into control flow blocks to a fixed depth of 12, which bounds the work a circuit can ask for. Anything below that depth is never visited, so an unsupported operation there produces no finding. Without this rule the result is indistinguishable from a circuit that really is compatible, and a caller would read the empty list as an answer it is not.
Never a defect in the circuit itself: it reports a limit of qxlint, not a mistake in the code. It is still worth acting on, because the checks that ran cover only part of the circuit. Flattening the nesting, or checking the inner blocks as circuits of their own, gives a complete answer.
# thirteen nested if_else blocks; the innermost is never visited
qxlint.check_target(deeply_nested, backend.target)The rule page sketches this circuit rather than building it, so no output is shown.
# the inner block checked as a circuit in its own right
qxlint.check_target(inner_block, backend.target)The rule page sketches this circuit rather than building it, so no output is shown.
QXL301
A backend accepts only the operations in its Target, on the qubit pairs its coupling map allows. Submitting anything else fails after the job is queued. This is a target compatibility check, not a hardware readiness check: readiness also involves layout, observable handling, option combinations and target freshness, none of which this rule looks at.
When the circuit is meant to be transpiled later. Running generate_preset_pass_manager(backend=...).run(qc) before submission is the normal fix, and an untranspiled circuit is not a defect on its own. This rule is only meaningful on a circuit you believe is already ISA compliant, which is why it is a library call rather than a file linting rule.
qc = QuantumCircuit(2)
qc.cx(0, 1)
qxlint.check_target(qc, backend.target) # cx is not native on Heron>>> qxlint.check_target(qc, backend.target)circuit-41[0]: QXL301 operation 'cx' on qubits [0, 1] is not supported by the target
pm = generate_preset_pass_manager(optimization_level=1, backend=backend)
qxlint.check_target(pm.run(qc), backend.target)The rule page sketches this circuit rather than building it, so no output is shown.
QXL302
A gate that is exactly its own inverse, applied twice in a row to the same ordered qubits, is the identity. The allow list is explicit and was built by squaring each operator matrix and comparing with the identity, not by guessing from the class name: iSwap, S, T, SX and DCX look similar and are not self inverse, so they are absent. Qubit order matters, so cx(0, 1) followed by cx(1, 0) does not cancel.
Benchmarking, noise characterization, randomized benchmarking and calibration sequences deliberately apply canceling pairs to build a known identity circuit. Dynamical decoupling sequences do the same. This is why the rule is preview and informational rather than default on. It also only looks at immediately adjacent instructions, because allowing anything in between requires commutativity analysis.
qc.h(0)
qc.h(0)The rule page sketches this circuit rather than building it, so no output is shown.
qc.h(0)
qc.x(0)
qc.h(0)The rule page sketches this circuit rather than building it, so no output is shown.
QXL303
A qubit with no operation on it contributes nothing to the result but still widens the circuit, which can force a larger layout and a worse transpilation. Barriers, delays and identity gates do not count as use.
Often, which is why this is preview and informational. A transpiled circuit is skipped outright, since it carries a layout and its width is the device's rather than the author's. Ancillas reserved for a later step, fixed width protocol circuits, templates and circuits built to a register size all legitimately carry unused qubits, and those are still reported.
qc = QuantumCircuit(3)
qc.h(0)
qc.cx(0, 1)>>> qxlint.check_circuit(qc, preview=True)circuit-41: QXL303 qubit 2 is declared but never operated on
qc = QuantumCircuit(2)
qc.h(0)
qc.cx(0, 1)>>> qxlint.check_circuit(qc, preview=True)No findings
How it decides
get_counts() is right on a BitArray and wrong on a DataBin, so the question is never whether a call appears. qxlint answers it with a small abstract interpreter that follows objects through aliases, containers, Qiskit’s library circuits and the transpile pipeline.
So an alias carries what was done through it.
qc = QuantumCircuit(1)
alias = qc
alias.measure_all()
sampler.run([qc]) # silent, the measurement is on the same objectThis is how most Sampler code is written, so it has to be analyzable.
qc = QuantumCircuit(1)
circuits = []
circuits.append(qc)
sampler.run(circuits) # QXL103 fires: the circuit is still trackedA call that cannot reach a circuit does not affect it; a call that receives it does.
qc = QuantumCircuit(2)
qc.h(0)
print("running") # cannot touch qc
sampler.run([qc]) # QXL103 fires
qc2 = QuantumCircuit(2)
helper(qc2) # may keep and mutate it
sampler.run([qc2]) # silentA rule fires only on what the analyzer can prove, never on maybe and never on unknown.
qc = QuantumCircuit(1)
if condition:
qc.measure_all()
sampler.run([qc]) # QXL103 stays silent: measured on some pathsIn every example sampler is a StatevectorSampler(). Each one is run through qxlint whenever this page is built, and the page is not published unless its comments still hold.
| Declared target | What QXL201 reports |
|---|---|
0.48, >=0.41 | An error: removed in 0.41 |
0.40.2, ==0.40.* | A warning: deprecated since 0.40 |
>=0.38,<0.43 | An error, because the range does not prove the code is safe |
| Not declared | An error, because an unstated target is read as current |
0.39 | Nothing, because a pin below 0.40 predates the deprecation |
The target comes from --target-runtime, then [tool.qxlint], then your project’s dependencies, an unambiguous uv.lock pin, or requirements.txt. Never from the Qiskit qxlint itself runs with.
Notebooks
.ipynb files are read directly, and what qxlint knows carries across cells in order. Magics are not blanked out, because blanking lies to the analyzer: each is handled by what it can actually do.
| Kind | Examples | Handling |
|---|---|---|
| Display or configuration | %matplotlib, %pip, !cmd | Dropped, facts kept |
| Python body | %%time, %%capture, %time | Header dropped, body analyzed |
| Namespace changing | %run, %load, %pylab, unknown magics | A barrier: facts before it are not trusted |
| Not Python | %%bash, %%sql, %%html | Whole cell dropped, a barrier |
Every rewrite keeps the line count, so a reported line is the line you see in the cell.
Integrations
The same analyzer behind every way in, so on the same version and configuration the editor and CI agree. Exit code 0 is clean, 1 is findings, and 2 means qxlint could not run.
Two hooks, one for Python files and one for notebooks.
repos:
- repo: https://github.com/TuguiDragos/qxlint
rev: v0.3.7
hooks:
- id: qxlint
- id: qxlint-notebookSARIF for code scanning. The tag pins the analyzer too, so @v0.3.7 installs qxlint 0.3.7.
- uses: TuguiDragos/qxlint@v0.3.7
with:
paths: .
format: sarif
output: qxlint.sarifDiagnostics in .py files and in notebook cells, from the same CLI and the same configuration as CI.
pip install qxlintA plugin for .py files. Use the CLI or nbqa for notebooks.
pip install qxlint flake8
flake8 --select=QXL .Adopt it on a project that already has findings: record them once, then gate on what is added.
qxlint --baseline-write qxlint-baseline.json
qxlint --baseline qxlint-baseline.jsonIn pyproject.toml. Silence one line with # noqa: QXL101.
[tool.qxlint]
select = ["QXL1", "QXL2"]
ignore = ["QXL102"]
target-runtime = "0.48"Tested
244 public repositories, chosen and pinned to a commit before qxlint was run on any of them.
50,385 Python files and 1,326 notebooks, from 243 different owners.
No repository made qxlint exit with code 2, which means it could not run.
320 of them outside QXL205, the rule for names Qiskit removed.
Each read in context and labeled. The labels are an AI reviewer’s, not yet confirmed by a person, and the write-up says so.
Scanned with qxlint 0.3.5 on August 23, 2026. The release gate: what is claimed, and what is not
qxlint is a free, open source linter for Python code that uses Qiskit. It finds the mistakes the move from the V1 to the V2 primitives introduced, such as reading counts off the wrong object or sampling a circuit that has no measurements, by reading your source. It never imports or runs it.
Run uvx qxlint . in your project without installing anything, or install it with uv tool install qxlint, pipx install qxlint, or pip install qxlint. It needs Python 3.11 to 3.14.
No. The source linter needs no Qiskit at all, makes no network requests, and needs no quantum hardware. Only the checks on circuits in memory need Qiskit, installed with pip install 'qxlint[circuit]'.
Yes. It reads .ipynb files directly and carries what it knows across cells in order. Each magic is handled by what it can do: display magics are dropped, cell magics with a Python body are analyzed, and magics that can rebind names stop the analysis from trusting what came before.
It tries hard not to. A rule fires only on facts the analyzer can prove, never on maybe and never on unknown, and every rule page says when the pattern it catches is legitimate. If that cannot be written, the rule does not ship.
Add # noqa: QXL101 to the line, or list the rule under ignore in [tool.qxlint] in pyproject.toml. On a project that already has findings, record them once with --baseline-write and gate on what is added with --baseline.
Yes: as a pre-commit hook, as a GitHub Action that writes SARIF for code scanning, as a flake8 plugin, and as a VS Code extension that runs the same CLI on the same [tool.qxlint] configuration, so the editor and CI agree as long as they run the same qxlint version and the extension’s own settings add nothing.
Yes. qxlint is free and open source under the MIT License.