Cram sheet — Section 7: Retrieve and analyze the results of quantum circuits
One-screen review layer for this section: short background primers per objective, then the key facts — each backed by an official source or by an executed proof from the question bank.
Retrieve previous experiment results (session/Qiskit Runtime)
Result anatomy: PrimitiveResult → PubResult → DataBin → BitArray
Every V2 job returns the same container chain; most retrieval questions ask at which layer an attribute lives.
| Layer | What it is | Reach in with | Does NOT have |
|---|---|---|---|
PrimitiveResult | list-like, one entry per PUB; len(result) is the PUB count | result[0], result[1] | .data, .get_counts(), .quasi_dists, .results |
PubResult (Sampler: SamplerPubResult) | one PUB's output: .data plus .metadata (shots or target_precision, circuit_metadata) | .data | get_counts(), evs |
DataBin | Estimator: evs and stds. Sampler: one BitArray per ClassicalRegister, keyed by the register NAME | .meas, db['meas'], list(db.keys()), values(), items(), shape, ndim, size | get_counts(), registers, num_bits |
BitArray | the shot data of one register | get_counts(), get_bitstrings(), get_int_counts() | .name — the name exists only as the DataBin key |
counts = result[0].data.meas.get_counts() # Sampler
evs = result[0].data.evs # Estimator
plot_histogram([result[0].data.meas.get_counts(),
result[1].data.meas.get_counts()], legend=["a", "b"])
evs and stds are real float64 arrays shaped like the PUB's broadcast shape; stds is the standard error achieved. plot_histogram needs one counts dict per legend entry.
With one BitArray per ClassicalRegister, splitting one register into alpha and beta gives data.alpha and data.beta, not one flat array.
A BitArray stores the bitstrings as BYTES, shots on the left axis; the docs recommend post-processing it rather than slow dictionaries.
| Member | What it gives you |
|---|---|
num_shots, num_bits | shots executed; CLASSICAL register width |
array | bit-PACKED uint8, shape (num_shots, ceil(num_bits/8)) |
get_counts() | bitstring to integer tally, summing to num_shots |
get_bitstrings() | the per-shot list, length num_shots |
get_int_counts() | the same tallies keyed by the bitstring read as a binary INTEGER |
postselect(indices, selection) | only the shots whose listed bits match |
slice_bits(indices), slice_shots(indices) | fewer bits, all shots; all bits, fewer shots — valid bit indices run 0..num_bits-1 |
BitArray.concatenate_shots([a, b]), concatenate_bits | pools shot sets of equal width; widens them instead |
expectation_values(observable) | diagonal expectation values straight from the shots |
Sources: primitive-input-output · qiskit.primitives.DataBin · qiskit.primitives.PrimitiveResult · qiskit.primitives.BitArray
Getting results back after the kernel dies
IBM Quantum automatically stores the results of EVERY job server-side, so dropping the job variable or restarting the kernel cancels and loses nothing. Keep the ID string from job.job_id().
| Goal | Call |
|---|---|
| one job back, by ID | service.job(job_id) returns the RuntimeJobV2; .result() gives the stored PrimitiveResult |
| find jobs when the ID is lost | service.jobs(...), the filtered LIST call, or the Workloads page |
| keep a result on disk | json.dump(result, f, cls=RuntimeEncoder) |
read it back as a PrimitiveResult | json.load(f, cls=RuntimeDecoder) |
Wrong spellings: service.result(...), service.get_result(...), service.retrieve_job(...) (the V1 provider idiom), and QiskitRuntimeService.job(...) called on the class, not an instance. Resubmitting is not retrieval: Runtime does not deduplicate, so an identical PUB becomes a new job with a new ID and freshly sampled shots.
| Mistake on the disk round trip | What you get |
|---|---|
| no encoder on dump | TypeError: Object of type PrimitiveResult is not JSON serializable |
RuntimeDecoder where the encoder belongs | TypeError |
plain json.load on an encoded file | nested dicts that look fine, then KeyError: 0 when you index the pub |
json.dumps(result, f, cls=RuntimeEncoder) | positional-argument TypeError: dumps/loads are the STRING variants, and everything after the first argument is keyword-only |
result.to_json() | no such method |
Sources: save-jobs · monitor-job
Must know:
num_bitsfollows the CLASSICAL register, not the circuit: a 5-qubit circuit measuring three qubits reportsnum_bits == 3, whilelen(get_bitstrings())andsum(get_counts().values())both equalnum_shots. — ⚙️ proven ins7-q019bits.arrayis bit-PACKEDuint8with shape(num_shots, ceil(num_bits/8)): a 10-qubit circuit sampled 1024 times gives(1024, 2)— not(1024, 10),(1024, 8)or the transpose. — ⚙️ proven ins7-q012- Counts keys are little-endian bitstrings: X on qubit 0 of a 3-qubit circuit sampled 500 times gives
{'001': 500}— integer tallies, not{'100': 500}, not fractions. — ⚙️ proven ins7-q014 bits.expectation_values('ZZ')computes a diagonal expectation value from the shots, no Estimator involved: an ideal Bell sample returns the 0-d scalar 1.0, one value per Pauli string. — ⚙️ proven ins7-q025- A
(4, 1)parameter array against one observable givesevs.shape == (4,)— the trailing parameter axis is consumed, so not(4, 1), not(1, 4). — ⚙️ proven ins7-q021 pub.data.stdsis the standard error ACHIEVED, not the requested precision and not1/sqrt(shots): on a noisy Bell/ZZ pub it read 0.0096 while both of those were 0.02. — ⚙️ proven ins7-q013
Traps:
- The missing PUB index is the classic bug:
result.data...raisesAttributeError: 'PrimitiveResult' object has no attribute 'data'; index the PUB first,result[0].data.meas.get_counts(). — ⚙️ proven ins7-q024 measis only the DEFAULT register name: measuring into a registerreadoutneedsresult[0].data.readout.get_counts();data.measraisesAttributeError. — ⚙️ proven ins7-q032bits.postselect([1], [0])returns a new BitArray of only the shots where qubit 1 read 0, at FULL width;postselecthas nonum_bits=parameter (TypeError), and a chained.slice_bits(...)silently narrows the result. — ⚙️ proven ins7-q016BitArray.concatenate_shots([a, b])pools equal-width shot sets (200 + 200 → 400 shots, 3 bits);a + bis aTypeError, andfrom_counts(a.get_counts() | b.get_counts())LOSES shots because dict union overwrites duplicate keys instead of summing. — ⚙️ proven ins7-q018result[0].data.stdsis a silent trap when you meantevs: same shape, it plots — but on adefault_precision = 0.02sweep the standard errors are about 0.005–0.018 while the expectation values span ±0.9, so the curve collapses onto the axis. — ⚙️ proven ins7-q038
Monitor jobs
The job handle: one string, several predicates, one blocking call
sampler.run(...) and estimator.run(...) return a RuntimeJobV2 immediately; the job executes server-side.
job.status() returns a PLAIN STRING, typed Literal['INITIALIZING', 'QUEUED', 'RUNNING', 'CANCELLED', 'DONE', 'ERROR'], hence official examples write if j.status() == "DONE". It is no JobStatus enum member and carries no queue information — queue position comes from metrics() or Workloads.
| Call | Blocks | What it does |
|---|---|---|
status() | no | returns the state string |
done(), errored(), cancelled(), running() | no | booleans built on that string; done() is literally status() == "DONE" |
in_final_state() | no | true for JOB_FINAL_STATES = ('DONE', 'CANCELLED', 'ERROR') — CANCELLED is terminal |
result() | YES | waits for a terminal state, then returns the PrimitiveResult; never partial shots |
wait_for_final_state(timeout, poll_interval) | yes | polls to a terminal state without fetching data |
cancel() | no | asks the service to drop the job; status() then reads "CANCELLED" |
job_id(), backend(), error_message(), logs(), metrics(), usage(), usage_estimation | no | bookkeeping |
Cancelling is a state change, not a deletion; a cancelled job returns no partial data. There is no job.stop() and no QiskitRuntimeService.cancel(): the service FINDS jobs, the job object owns the lifecycle. Deleting your local variable leaves the job queued, still consuming your instance's time. qiskit.tools.job_monitor disappeared with Qiskit 1.0; monitor with status() in a loop, wait_for_final_state(), or the Workloads page.
Sources: runtime-job-v2 · monitor-job
Watching a session, and the platform-side view
session.details() returns a DICTIONARY describing the SESSION, not its jobs.
| Key | Meaning |
|---|---|
id, backend_name, mode | which session, on what backend, in which mode |
state | one of open, active, inactive, closed |
accepting_jobs | whether new jobs are still taken |
max_time | maximum session length in seconds, subject to plan limits |
interactive_timeout, active_timeout | idle gap allowed between jobs; how long it may stay active |
usage_time | the QPU-committed time |
started_at, activated_at, last_job_started, last_job_completed, closed_at | lifecycle timestamps |
Nothing there is per-job: queue position belongs to a job, results to job.result(). In local testing mode (a Session on a fake backend) details() and session_id are both None.
close() stops the session ACCEPTING new jobs while queued and running jobs finish, and it terminates once nothing is pending; cancel() cancels all PENDING jobs in the session. The rest of the surface, shared with Batch: backend(), status(), usage(), from_id(session_id, service) and the service/session_id attributes. Neither class has a public run; run through a primitive constructed with mode=session.
The platform pages:
| Page | What it shows |
|---|---|
| Workloads | every workload you submitted, with its Status column and job ID; cancel from the row's overflow menu or a workload's Actions dropdown |
| Instances | total plan time used and remaining |
| Analytics | job, batch and session counts, for accounts you own or manage |
Sources: session · monitor-job
Must know:
- Cancel through the job handle —
job.cancel(), or the Cancel action on the Workloads page;cancel()raisesRuntimeInvalidStateErrorif the job's state cannot be cancelled. — 📖 runtime-job-v2 service.jobs()lists jobs when you have no ID:limitdefaults to 10, and it filters onbackend_name,pending,session_id,created_after/created_before(datetime objects) and more. — 📖 monitor-jobdetails()["state"]usesopen/active/inactive/closed;session.status()usesPending,In progress, accepting new jobs,In progress, not accepting new jobsandClosed. — 📖 session
Traps:
- On a QPU job,
status()returns a plainstr(local testing mode returns aJobStatusenum instead), sostatus() == JobStatus.DONEis False andstatus().nameraisesAttributeError.done()is True only forDONE;in_final_state()also coversCANCELLEDandERROR; there is noFAILEDstate. — ⚙️ proven ins7-q034 service.jobs(session_id=sid)still applies the defaultlimit=10: a 14-job session silently returns only its 10 newest jobs, though the docstring says all are returned. Passlimit=Noneto get all 14. — ⚙️ proven ins7-q039- On an
ERRORjob,result()raisesRuntimeJobFailureErrorat once ('Unable to retrieve job result. …') andjob.error_message()gives the reason; aCANCELLEDjob raisesRuntimeInvalidStateErrorinstead. — ⚙️ proven ins7-q043
- Index the PUB first:
result[0].data.<register>.get_counts()—PrimitiveResulthas no.data. - Ask
list(db.keys())for the register name instead of assumingmeas. - Read
bits.arrayas packed bytes:uint8, shape(num_shots, ceil(num_bits/8)). - Pick the post-processing method by axis:
postselectandslice_shotsdrop shots,slice_bitsdrops bits. - Save
job.job_id(); after a restart, re-hydrate withservice.job(job_id).result(). - Round-trip a result through disk with
RuntimeEncoderon dump,RuntimeDecoderon load. - Compare
job.status()against the plain string"DONE", never aJobStatusenum member. - Separate the session verbs:
close()stops accepting new jobs,cancel()kills the pending ones.
Every fact above is sourced: 📖 links go to official documentation, ⚙️ marks facts observed by executing code against the pinned Qiskit stack (the linked section page shows the proof evidence on its practice questions).