qxlint
Deterministic static checks for Qiskit Primitives V2 workflows.
qxlint finds the mistakes the Qiskit V1-to-V2 primitives migration introduced: reading counts off the wrong object, using a V1 field on a V2 result, sampling a circuit that has no measurements, and passing a channel value a targeted release has removed.
It reads your source. It never imports it and never executes it, it makes no network requests, and it needs no quantum hardware.
uvx qxlint .
app.py:31:10: QXL101 get_counts() on a PrimitiveResult; counts live on the BitArray in PrimitiveResult -> PubResult -> .data (DataBin) -> <classical register> (BitArray)
app.py:44:1: QXL103 circuit has no measurement instructions but is passed to a SamplerV2; the result carries no counts
service.py:7:31: QXL201 channel="ibm_quantum" was removed in qiskit-ibm-runtime 0.41; omit the channel argument
Why it exists
Two things fail quietly in Primitives V2 code.
The result shape changed and the errors arrive late. Counts now live at
result[i].data.<classical register>, on a BitArray. Calling get_counts()
one level too high raises AttributeError, but only after the job has run.
An unmeasured circuit does not fail at all. With no classical register,
Qiskit 2.5.2 emits a UserWarning and returns an empty data bin. With a
classical register but no measure instruction there is no warning whatsoever,
and every shot reads as zeros, which looks like a physics result rather than a
bug. This is the case qxlint was built for.
Both are decidable statically, and neither needs a model, a network or a quantum computer.
Install
| Run without installing | uvx qxlint . |
| Install as a tool | uv tool install qxlint or pipx install qxlint |
| Add to a project | pip install qxlint |
| From a JavaScript toolchain | npx @tuguidragos/qxlint ., the npm launcher; still needs Python |
| In VS Code | the qxlint extension, also on Open VSX |
| With the circuit checks | pip install 'qxlint[circuit]' |
Python 3.11 to 3.14. Qiskit is optional: the source linter needs no Qiskit at all, and only the in-memory circuit checks require it installed.
Rules
| Code | Tier | Fires when |
|---|---|---|
| QXL000 | default | the file or notebook cell cannot be parsed |
| QXL101 | default | get_counts() on a PrimitiveResult, PubResult or DataBin |
| QXL102 | default | quasi_dists or values read from a V2 PrimitiveResult |
| QXL103 | default | a provably unmeasured circuit reaches a SamplerV2 |
| QXL104 | default | a circuit method returning a new circuit whose result is dropped |
| QXL105 | default | a measured circuit reaches a StatevectorEstimator |
| QXL201 | default | channel="ibm_quantum" on a target that removed it |
| QXL202 | default | Runtime SamplerV2 given backend= or session= instead of mode= |
| QXL203 | default | Session or Batch given service=, removed in qiskit-ibm-runtime 0.34 |
| QXL204 | default | a V2 primitive's run() called with the V1 argument grammar |
| QXL205 | default | an import, or a QuantumCircuit method call, naming something Qiskit 1.0 or 2.0 removed |
| QXL300 | default, library | control flow nests deeper than the circuit walker descends |
| QXL301 | default, library | a circuit uses an operation the Target does not support, control flow operations included |
| QXL302 | preview, library | two adjacent identical self inverse gates cancel |
| QXL303 | preview, library | a qubit is declared but never operated on |
Every rule page documents when the pattern is legitimate. If that section cannot be written, the rule does not ship.
The four marked library work on an in-memory circuit and are reached through
qxlint.check_target and qxlint.check_circuit, not by linting a file.
The same table is available from the tool itself, with no documentation checked out and no network:
qxlint --list-rules
qxlint --explain QXL203
Precision over recall
A false positive costs more than a missed finding, so a rule fires only on facts
the analyser can prove. Rules never fire on MAYBE and never on UNKNOWN.
qc = QuantumCircuit(1)
if condition:
qc.measure_all()
sampler.run([qc]) # QXL103 stays silent: measured on some paths
A missed detection is not free either, which is why the semantic layer models aliases, containers, the library circuits and the transpile pipeline rather than giving up on them.
How it decides
The valuable rules cannot be AST pattern matches. get_counts() is correct on a
BitArray and wrong on a DataBin; the question is never "does this call
appear" but "what is this object". qxlint answers that with a small abstract
interpreter. Full design.
Names bind to object identities, not to facts, so aliases work:
qc = QuantumCircuit(1)
alias = qc
alias.measure_all()
sampler.run([qc]) # silent, the measurement is on the same object
A local container is not an escape. This is how most Sampler code is written, so it has to be analysable:
circuits = []
circuits.append(qc)
sampler.run(circuits) # the circuit is still tracked
Effects are scoped. A 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]) # silent
Library circuits are circuits. RealAmplitudes, EfficientSU2,
ZFeatureMap, QAOAAnsatz and the rest of qiskit.circuit.library are modelled,
so an ansatz is not an opaque object the rules cannot reach.
Version gated rules
A version specifier is not a version. >=0.38,<0.43 spans releases where
channel="ibm_quantum" is valid, deprecated and removed, so QXL201 asks what the
target proves rather than what it merely allows.
| Declared target | QXL201 |
|---|---|
0.48, >=0.41 |
error: removed in 0.41 |
0.40.2, ==0.40.* |
warning: deprecated since 0.40 |
>=0.38,<0.43 |
error, the range does not prove the code is safe |
| not declared | error, an unstated target is read as current |
Silence is reserved for a target that proves the value still works, which means a pin below 0.41. A range spanning the change does not prove that, and neither does saying nothing: a project that never states a version is far more likely to be on today's release than on one from before 0.41. Reading an undeclared target as silent hid the finding from every project without a pin, which is why it now reports.
Targets come from --target-runtime, then [tool.qxlint], then the analysed
project's pyproject.toml dependencies, then an unambiguous uv.lock pin, then
requirements.txt, whose -r includes are followed.
qxlint never inspects its own installed Qiskit for this: the version in the
linter's environment is not the version your project targets.
Notebooks
.ipynb files are analysed directly, and semantic facts carry across cells in
textual order.
Magics are not blanked out, because blanking lies to the analyser. %run can
rebind any name, so treating it as a no-op would leave stale facts and produce a
false positive. Magics are sorted by what they can actually do:
| Kind | Examples | Handling |
|---|---|---|
| Display or config | %matplotlib, %pip, !cmd |
dropped, facts kept |
| Python body | %%time, %%capture, %time |
header dropped, body analysed |
| Namespace mutating | %run, %load, %pylab, unknown magics |
semantic barrier |
| Non Python body | %%bash, %%sql, %%html |
whole cell dropped, barrier |
Line counts are preserved by every rewrite, so a reported line is the line you
see in the cell. Findings carry a 1-based cell_index over code cells, matching
nbqa. Details and limits.
Integration
Adopting it on a project that already has findings
Record what is there today, then gate on what is added after:
qxlint --baseline-write qxlint-baseline.json # once, committed
qxlint --baseline qxlint-baseline.json # in CI
An entry keys on the path, the rule and the message, not the line, so a baseline survives unrelated edits. Repeats are counted, so a third copy of an accepted mistake is still reported. See docs/configuration.md for the whole contract.
pre-commit
repos:
- repo: https://github.com/TuguiDragos/qxlint
rev: v0.3.5
hooks:
- id: qxlint
- id: qxlint-notebook
GitHub Actions with SARIF
permissions:
contents: read
security-events: write
jobs:
qxlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: TuguiDragos/qxlint@v0.3.5
with:
paths: .
format: sarif
output: qxlint.sarif
- uses: github/codeql-action/upload-sarif@v4
if: always()
with:
sarif_file: qxlint.sarif
The tag pins the analyser too: @v0.3.5 installs qxlint 0.3.5, not whatever
PyPI holds on the day the workflow runs. Override it with the version input
when you want something else.
paths takes one path per line, so a name containing a space is one path:
with:
paths: |
src
quantum notebooks/demo.ipynb
security-events: write must be granted by the calling workflow; an action
cannot grant it to itself. Private and internal repositories additionally need
GitHub code security enabled.
VS Code
Install the qxlint extension, then install the analyser into the environment you have selected:
pip install qxlint
Diagnostics appear in .py files and in notebook cells, and each rule code in
the Problems panel links to its documentation page. The extension runs the same
CLI with the same [tool.qxlint] configuration, so the editor and CI cannot
disagree.
With the Python extension installed it follows the interpreter you have
selected. Without it, point qxlint.path at the executable, or have one on
PATH. Source in vscode/.
flake8
pip install qxlint flake8
flake8 --select=QXL .
VS Code does not pick this up automatically, because the Python extension uses its own bundled flake8:
{ "flake8.importStrategy": "fromEnvironment" }
The plugin covers .py only. Use the qxlint CLI or nbqa for notebooks.
Configuration
[tool.qxlint]
select = ["QXL1", "QXL2"]
ignore = ["QXL102"]
preview = false
extend-exclude = ["vendor"]
target-qiskit = ">=2.0"
target-runtime = "0.48"
[tool.qxlint.per-file-ignores]
# Test suites deliberately exercise the unmeasured path.
"tests/*" = ["QXL103"]
"notebooks/*" = ["QXL103"]
.venv, .git, node_modules, build, dist and the usual caches are
skipped already. extend-exclude adds to that list; exclude replaces it, for
the rarer case where you want to lint inside one of them.
Suppress a single line with # noqa: QXL101, or every rule on it with # noqa.
A code is a prefix, as in flake8, so # noqa: QXL1 covers every QXL1xx rule on
that line. All options.
Summary view
qxlint --statistics .
QXL205 ████████████████████████ 3896 removed-qiskit-symbol
QXL104 █░░░░░░░░░░░░░░░░░░░░░░░ 35 discarded-circuit-result
QXL101 █░░░░░░░░░░░░░░░░░░░░░░░ 7 get-counts-on-wrong-receiver
QXL102 █░░░░░░░░░░░░░░░░░░░░░░░ 2 v1-result-field-on-v2-result
QXL204 █░░░░░░░░░░░░░░░░░░░░░░░ 2 v1-run-signature
3942 findings across 2353 files of 13861 scanned
That is real output from one repository in the corpus below.
Per rule counts, which is how results are published. Works with --format json
too. The exit code is unchanged, so a CI gate behaves the same with or without it.
Colour is emitted only when the destination can show it. NO_COLOR is honoured,
a pipe never receives escape sequences, and 24-bit degrades to 256, then to 16,
then to none. Histogram blocks fall back to ASCII when the encoding cannot carry
them.
Analysing a buffer instead of a file
cat unsaved.py | qxlint --stdin-filename src/unsaved.py --format json
The path is metadata. It selects the configuration and decides whether the text is Python or a notebook, and the file itself is never opened, so this works on something that has not been saved or does not exist. This is how the VS Code extension analyses what is on screen rather than what is on disk.
Notebooks work the same way: send the .ipynb JSON and name the path
something.ipynb.
Exit codes. 0 clean, 1 findings, 2 qxlint could not run. A file that
does not parse is a finding (QXL000), not an internal error, so a non-zero exit
always means "look at the output". One unreadable file never ends the run.
A --select that matches no rule is exit 2, not a clean run: leaving every rule
off would take a CI gate green without having checked anything. Same for a
--target-* value that is not a version.
Circuit checks (library API)
These operate on an in-memory circuit, so they have no file:line. They report
a circuit name and an instruction path that stays unambiguous inside control
flow blocks.
import qxlint
for finding in qxlint.check_target(isa_circuit, backend.target):
print(finding.render_text())
# circuit-44[2].block[0][0]: QXL301 operation 'cy' on qubits [0, 1] is not supported by the target
qxlint.check_circuit(qc, target=backend.target, preview=True)
qxlint.assert_sampler_ready(pubs, target=...) is the runtime answer to what the
source analyser cannot prove. It reads the real circuits, so it decides every
call rather than the third it can see in source, and raises NotSamplerReady
carrying the same Finding objects. Call it immediately before sampler.run:
qxlint.assert_sampler_ready(pubs, target=backend.target)
job = sampler.run(pubs)
ignore drops findings whose code starts with any prefix given, which is the
only way to silence a circuit rule: the finding has no source line, so # noqa
cannot reach it, and the library API reads no [tool.qxlint] section.
check_circuit with neither target nor preview returns an empty list, and
says so rather than leaving it as a surprise: QXL301 needs a target, and the
other two circuit rules are preview tier.
A target that is not a Qiskit Target raises TypeError. It used to return
no findings, which is the one answer a compatibility check must never give when
it did not run.
How it is tested
Every claim on this page is backed by something that runs.
Against its own suite
| Tests | 1368, on Python 3.11, 3.12, 3.13 and 3.14, each job proving it runs the interpreter it is named after |
| Qiskit matrix | 2.5.2, the declared floor 2.0.3, and a job with no Qiskit installed at all |
| Coverage | 100% of statements and branches, enforced as a CI gate, not reported as a number |
| Types | mypy --strict, clean |
| Style | ruff check and ruff format --check, clean |
| API model | 396 checks against a real Qiskit install, run on a schedule so an upstream change is a test failure rather than a user report |
| Mutation testing | 46 hand written mutations, each changing one documented behaviour. 44 were caught; the 2 survivors were each verified to be equivalent mutants |
The API tables were built by introspecting an installed Qiskit, not by reading documentation prose. The self inverse gate list was built by squaring operator matrices.
Against code it has never seen
244 public repositories, selected and pinned to commit SHAs before the linter was run on any of them.
| Repositories | 244, from 243 distinct owners |
| Files read | 51,711: 50,385 .py and 1,326 .ipynb |
| Crashes, timeouts, exit code 2 | 0 |
| Non deterministic results | 0 |
| Findings read and labelled individually | 407 |
| QXL205 findings, counted in aggregate, 52 sampled and read | 5,963 |
| of which workflow defects rather than unparsable files | 237 |
| False positives, three defects, all since fixed | 35 |
| Findings the current tree reports | 6,283, of which 320 outside QXL205 |
| Defects the corpus found in qxlint, and fixed | 10 |
The 35 are three defects. The first two are in the same place. The first, 30 rows: the notebook
magic detector required a letter after the !, so ! pip install x,
!{sys.executable} -m pip install x and !./run.sh stayed in the cell and it
was reported as unparsable. The second, 3 rows: a cell holding a bare
pip install x or pip list was read as unparsable, but IPython automagic
rewrites such a line and the cell runs.
Both have the same root. Handing the cell to CPython, which is how QXL000 was checked, cannot catch either: neither a shell escape nor a bare magic is valid Python. The question for a notebook cell is what IPython accepts, and the scan never asked it.
The third, 2 rows: assign_parameters was read for a keyword inplace only, so
block.assign_parameters(params, [0, 1]) looked like a discarded result. The
second positional argument is inplace, and a non empty list is truthy, so the
call mutates the receiver.
Three of the QXL104 findings are in Qiskit's own test suite, where a circuit method that returns a new circuit is called as a bare statement:
qc.compose(CCXGate().definition, [0, 1, 2], []) # Unroll CCX to 2q operations.
qc.assign_parameters({theta: 3.14})
circ.inverse()
compose and assign_parameters default to inplace=False and inverse
always returns, so the comment on the first line describes something that does
not happen.
Every finding was read in context and labelled, with the line it was reported
on, in findings.csv. Every one of those 407 labels was
written by an AI reviewer, claude-opus-5, and none has been confirmed by a
human yet, which is recorded in the reviewer column of every row. Read the
corpus as evidence of robustness and determinism, which it measures directly,
and not as a measurement of precision, which it does not. Full write up in
corpus/.
What a pattern matcher would have done. The corpus contains 4,003
.get_counts( calls and 286 quasi_dists occurrences. A linter matching
those textually would have reported 4,289 findings. qxlint reports 18, each on a
V2 result object; the other 4,271 are correct legacy or V1 code, where
get_counts() is exactly right.
Against hostile input
Encodings the file does not declare, UTF-16 and cp1252 notebooks, BOMs, null bytes, CRLF and lone CR, symlink loops, symlinks to ancestors, broken symlinks, named pipes, unreadable directories, 60 levels of nesting, expressions deeper than the parser can follow, 200,000 line cells, notebooks whose JSON is broken and notebooks that are not JSON at all. None of them crashes the run, hangs it, or costs the findings in the rest of the tree.
Speed
The whole corpus, 51,711 files across 244 repositories, is 77 seconds in one process on one laptop core. The largest repository in it, 13,861 files, takes 18 seconds. The median repository holds 11 files and the ninetieth percentile 141, so a typical repository is a fraction of a second, which is what makes it invisible in a pre-commit hook.
Honest limits
- No interprocedural analysis. A circuit mutated inside a helper is not tracked, a circuit reaching a primitive as a parameter or a return value carries no facts, and calling a function defined in the same module invalidates every fact.
- A function body inherits imports, not data. A name bound at module level
is unknown inside a function, because the order in which functions run is not
known and assuming one would invent facts. So a module level
samplerused inside a function reaches the rules as unknown, and nothing fires. This is measured, not guessed: on the corpus as it stood at 218 repositories it cost 8 of 437 primitive calls and no findings at all. Details. - The finding set is per interpreter. qxlint parses with the CPython running
it, so syntax that changed between versions is judged by that version. An
f-string with nested same quotes,
f"{d["k"]}", is a syntax error on 3.11 and valid from 3.12, and QXL000 follows. Run the same interpreter locally and in CI, as you would for any other linter. - No precision figure is published. Every finding across the corpus was read and labelled, 35 of the 407 as false positives from three defects since fixed, but the single reviewer was an AI and its labels are still unconfirmed by a human, which is not an independent precision measurement. See the release gate for exactly what is and is not claimed.
- Recall is measured for one rule only. For the removed channel, where the
textual pattern is precise enough to build a trustworthy denominator, qxlint
reports 79 of the 81 live call sites in the corpus. The two it misses are
behind an import that is commented out, or one whose
except ImportErrorbranch rebinds the name, where silence is the designed behaviour. - Notebook automagic is read only where it is decidable.
pip install qiskitis not valid Python, so a line naming a known magic that does not parse is read as the magic IPython would run. A barelsis valid Python and is left alone. Out-of-order interactive execution cannot be reconstructed; analysis assumes cells run top to bottom. - SARIF physical source locations are guaranteed for
.pyfiles. Notebook findings carry logical cell locations, so inline pull request annotations are not guaranteed for them. Circuit findings have no file at all. - Every statement qxlint makes about IBM Runtime is about its client side validation, read from its source. qxlint asserts nothing about server behaviour.
Prior art
qxlint is not the first Qiskit linter and does not claim to be.
| Project | What it does | Overlap with qxlint |
|---|---|---|
| flake8-qiskit-migration 0.5.0 | 6 checks (QKT100-QKT202), 207 data entries, all deprecated or removed name lookup |
None. It answers "does this still exist"; qxlint answers "is this workflow correct". Run both. |
| LintQ (FSE 2024) | 10 CodeQL analyses, 91.0% precision with its 6 default analyses over 7,568 programs | None of its ten evaluated analyses touches Primitives V2 PUBs, observables or layout. Needs a CodeQL database and Docker. |
| QChecker, QSmell | research prototypes, AST and execution trace based | Different targets, both effectively unmaintained |
| lique, qasmtools | Rust general quantum linter; OpenQASM 3 toolkit | Adjacent, neither targets the Qiskit Python API |
| LintQ-LLM (arXiv 2504.05204), arXiv 2605.03943 | LLM based linting, F1 0.70 vs LintQ's 0.41 | See below |
Full comparison with sources: docs/prior-art.md.
On the argument that rule-based quantum linters cannot keep up
"Beyond Rules: LLM-Powered Linting for Quantum Programs" (May 2026) argues that rule-based quantum linters struggle to keep pace with rapidly evolving APIs. That is a fair criticism of a linter built as a list of API names, and it is part of why qxlint implements no migration rules at all.
It is a weaker criticism of this design. qxlint targets Primitives V2, which is the stabilised surface rather than the moving one: V1 primitives were removed in Qiskit 2.0 and the V2 shape is now the supported path. Version dependent rules consult a declared target and stay silent when it proves the code still works, so an API change makes qxlint quieter rather than wrong on the projects that state their version. And determinism is not a stylistic preference in CI: the same commit must produce the same findings, at no per-run cost, offline, with a reviewable reason for every one.
Both approaches can be right. An LLM linter is a good fit for open-ended review; a deterministic one is a good fit for a merge gate.
Documentation
- Rules, generated from the rule modules
- The semantic layer, the analysis contract
- Configuration
- Notebooks
- Release gate, what is claimed and what is not
- Prior art
- External corpus, the scans and their labelled findings
Contributing
Setup, the bar a new rule has to clear, and what runs before a pull request: CONTRIBUTING.md.
uv sync
uv run pytest
License and trademark
MIT. Author: Tugui Dragos, https://tuguidragos.com
Qiskit is a trademark of IBM Corporation. qxlint is an independent project and is not affiliated with or endorsed by IBM.
Release files for qxlint 0.3.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| qxlint-0.3.5.tar.gz | 185.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| qxlint-0.3.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 308.3 kB
Release files / qxlint-0.3.5.tar.gz
| Download URL | qxlint-0.3.5.tar.gz |
|---|---|
| Size | 185.9 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6e752901b05c283210fe0e9c1fbf49d597d007d240e332017d49f27c0fa3800c
|
|
BLAKE2b-256 checksum How to use checksums |
941b949c3ff722db6f2690742d0af1675a5dafa5b5ca5ea5c1d0f77724b2d065
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency logRelease files / qxlint-0.3.5-py3-none-any.whl
| Download URL | qxlint-0.3.5-py3-none-any.whl |
|---|---|
| Size | 122.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
325e5d6332986ce1e9c9b1d333cc87efe410129074510048d7ea69ad788e6bc4
|
|
BLAKE2b-256 checksum How to use checksums |
c77a88836585542b39754bd1170281a630cb59db2cdae2ae01d302f96d184bdf
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 23, 2026.
Transparency log