Skip to main content

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.

LayerWhat it isReach in withDoes NOT have
PrimitiveResultlist-like, one entry per PUB; len(result) is the PUB countresult[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).dataget_counts(), evs
DataBinEstimator: evs and stds. Sampler: one BitArray per ClassicalRegister, keyed by the register NAME.meas, db['meas'], list(db.keys()), values(), items(), shape, ndim, sizeget_counts(), registers, num_bits
BitArraythe shot data of one registerget_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.

MemberWhat it gives you
num_shots, num_bitsshots executed; CLASSICAL register width
arraybit-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_bitspools 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().

GoalCall
one job back, by IDservice.job(job_id) returns the RuntimeJobV2; .result() gives the stored PrimitiveResult
find jobs when the ID is lostservice.jobs(...), the filtered LIST call, or the Workloads page
keep a result on diskjson.dump(result, f, cls=RuntimeEncoder)
read it back as a PrimitiveResultjson.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 tripWhat you get
no encoder on dumpTypeError: Object of type PrimitiveResult is not JSON serializable
RuntimeDecoder where the encoder belongsTypeError
plain json.load on an encoded filenested 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_bits follows the CLASSICAL register, not the circuit: a 5-qubit circuit measuring three qubits reports num_bits == 3, while len(get_bitstrings()) and sum(get_counts().values()) both equal num_shots. — ⚙️ proven in s7-q019
  • bits.array is bit-PACKED uint8 with 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 in s7-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 in s7-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 in s7-q025
  • A (4, 1) parameter array against one observable gives evs.shape == (4,) — the trailing parameter axis is consumed, so not (4, 1), not (1, 4). — ⚙️ proven in s7-q021
  • pub.data.stds is the standard error ACHIEVED, not the requested precision and not 1/sqrt(shots): on a noisy Bell/ZZ pub it read 0.0096 while both of those were 0.02. — ⚙️ proven in s7-q013

Traps:

  • The missing PUB index is the classic bug: result.data... raises AttributeError: 'PrimitiveResult' object has no attribute 'data'; index the PUB first, result[0].data.meas.get_counts(). — ⚙️ proven in s7-q024
  • meas is only the DEFAULT register name: measuring into a register readout needs result[0].data.readout.get_counts(); data.meas raises AttributeError. — ⚙️ proven in s7-q032
  • bits.postselect([1], [0]) returns a new BitArray of only the shots where qubit 1 read 0, at FULL width; postselect has no num_bits= parameter (TypeError), and a chained .slice_bits(...) silently narrows the result. — ⚙️ proven in s7-q016
  • BitArray.concatenate_shots([a, b]) pools equal-width shot sets (200 + 200 → 400 shots, 3 bits); a + b is a TypeError, and from_counts(a.get_counts() | b.get_counts()) LOSES shots because dict union overwrites duplicate keys instead of summing. — ⚙️ proven in s7-q018
  • result[0].data.stds is a silent trap when you meant evs: same shape, it plots — but on a default_precision = 0.02 sweep the standard errors are about 0.005–0.018 while the expectation values span ±0.9, so the curve collapses onto the axis. — ⚙️ proven in s7-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.

CallBlocksWhat it does
status()noreturns the state string
done(), errored(), cancelled(), running()nobooleans built on that string; done() is literally status() == "DONE"
in_final_state()notrue for JOB_FINAL_STATES = ('DONE', 'CANCELLED', 'ERROR') — CANCELLED is terminal
result()YESwaits for a terminal state, then returns the PrimitiveResult; never partial shots
wait_for_final_state(timeout, poll_interval)yespolls to a terminal state without fetching data
cancel()noasks the service to drop the job; status() then reads "CANCELLED"
job_id(), backend(), error_message(), logs(), metrics(), usage(), usage_estimationnobookkeeping

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.

KeyMeaning
id, backend_name, modewhich session, on what backend, in which mode
stateone of open, active, inactive, closed
accepting_jobswhether new jobs are still taken
max_timemaximum session length in seconds, subject to plan limits
interactive_timeout, active_timeoutidle gap allowed between jobs; how long it may stay active
usage_timethe QPU-committed time
started_at, activated_at, last_job_started, last_job_completed, closed_atlifecycle 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:

PageWhat it shows
Workloadsevery workload you submitted, with its Status column and job ID; cancel from the row's overflow menu or a workload's Actions dropdown
Instancestotal plan time used and remaining
Analyticsjob, 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() raises RuntimeInvalidStateError if the job's state cannot be cancelled. — 📖 runtime-job-v2
  • service.jobs() lists jobs when you have no ID: limit defaults to 10, and it filters on backend_name, pending, session_id, created_after/created_before (datetime objects) and more. — 📖 monitor-job
  • details()["state"] uses open/active/inactive/closed; session.status() uses Pending, In progress, accepting new jobs, In progress, not accepting new jobs and Closed. — 📖 session

Traps:

  • On a QPU job, status() returns a plain str (local testing mode returns a JobStatus enum instead), so status() == JobStatus.DONE is False and status().name raises AttributeError. done() is True only for DONE; in_final_state() also covers CANCELLED and ERROR; there is no FAILED state. — ⚙️ proven in s7-q034
  • service.jobs(session_id=sid) still applies the default limit=10: a 14-job session silently returns only its 10 newest jobs, though the docstring says all are returned. Pass limit=None to get all 14. — ⚙️ proven in s7-q039
  • On an ERROR job, result() raises RuntimeJobFailureError at once ('Unable to retrieve job result. …') and job.error_message() gives the reason; a CANCELLED job raises RuntimeInvalidStateError instead. — ⚙️ proven in s7-q043
Exam checklist
  • Index the PUB first: result[0].data.<register>.get_counts() — PrimitiveResult has no .data.
  • Ask list(db.keys()) for the register name instead of assuming meas.
  • Read bits.array as packed bytes: uint8, shape (num_shots, ceil(num_bits/8)).
  • Pick the post-processing method by axis: postselect and slice_shots drop shots, slice_bits drops bits.
  • Save job.job_id(); after a restart, re-hydrate with service.job(job_id).result().
  • Round-trip a result through disk with RuntimeEncoder on dump, RuntimeDecoder on load.
  • Compare job.status() against the plain string "DONE", never a JobStatus enum 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).