Write logs and handle errors
Set up your workflow code to write logs, report errors to your local notebook, and save partial work when a job is canceled. To read these outputs from your local notebook, see Run a managed workflow.
In the workflow | In your local notebook |
|---|---|
Write with a logger from the logging module, or call print(). | job.logs() returns the lines. |
| Raise an exception, or exit with a nonzero code. | job.status() returns ERROR, and job.logs() shows the traceback. |
Call send_error(). | job.error_message() and job.events(type="ERROR") return the code, message, and details. |
Call save_result() in a SIGTERM handler. | job.result() returns the partial result of a canceled job. |
Write logs
Anything that the workflow writes to standard output or standard error goes to the job's logs. Read the logs with job.logs() while the job runs or after it ends.
Set up a logger
Use the logging module from the Python standard library. logging.basicConfig() writes to standard error, which reaches the logs without delay. The format in the following example adds a timestamp to each line:
import logging
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
logger = logging.getLogger("my_workflow")
logger.setLevel(logging.INFO)
logger.info("Transpiled %d circuits: depth %d -> %d", len(circuits), 1250, 730)2026-11-01 21:57:35,195 INFO my_workflow: Transpiled 12 circuits: depth 1250 -> 730level=logging.WARNING hides INFO messages from other packages, and logger.setLevel(logging.INFO) keeps INFO messages for your own logger. qiskit_ibm_runtime sets its own level with the QISKIT_IBM_RUNTIME_LOG_LEVEL environment variable.
print() also reaches the logs, but standard output is buffered, so printed lines might not appear until the job ends. If you use print(), pass flush=True.
Set the log level for each run
To debug one run without changing the code, pass the log level as an argument:
job = my_workflow.run(circuits=circuits, log_level="DEBUG")To enable this, set the level of your logger from the argument:
from qiskit_serverless import get_arguments
arguments = get_arguments()
logger.setLevel(arguments.get("log_level", "INFO"))If the workflow has an argument schema, declare log_level in it, for example as {"enum": ["DEBUG", "INFO", "WARNING"]}.
Best practice: decide what to log
Log enough to reconstruct a run from its logs:
- At the start, log what the run received. Log the backend name, the number of circuits, and the other settings that change between runs.
- Log the start and end of each stage. Use fixed messages that you can search for.
- Log iterations at a fixed interval. For example, log a line such as
iteration 10/200, energy=-1.1000every 10 iterations, instead of every iteration. - Log exceptions with
logger.exception()inside anexceptblock. The line includes the full traceback, even if the workflow recovers from the error. - Turn off progress bars. The logs store each refresh of a progress bar, such as one from
tqdm, as a separate line. Passdisable=Truetotqdm, or log at an interval instead. - Keep the logs small. Do not log large data, such as every shot. A managed workflow keeps only the newest 50 MB of logs for each job.
- Use the words "error" and "exception" only in messages about failures. If a failed job did not report its error, the exception that
job.result()raises contains only the log lines that include these words.
Best practice: report errors with send_error()
When the workflow raises an exception, the job ends with the ERROR status, and the traceback goes to the logs. A managed workflow does not store the error, even for a ServerlessError. job.error_message() returns {}, and the exception that job.result() raises contains only the log lines that include error or exception. Checks of argument types and ranges belong in an argument schema, which rejects a bad run before the job starts.
Report every error that stops the workflow with send_error(), so that your local notebook gets a short error with a code and details:
print(job.error_message())
| Message: The circuit has more qubits than the backend.
| Code: 4500
| Exception: InputError
| Details:
| - circuit_qubits: 10
| - backend_qubits: 5job.result() raises a QiskitServerlessException with the same text.
To enable this, raise ServerlessError where the error happens, and wrap the code of your entrypoint in a try block that sends any error with send_error(). A workflow adds this handler to Catalog Functions, but not to managed workflows:
from qiskit_serverless import ServerlessError, send_error
class InputError(ServerlessError):
"""The arguments are valid, but the workflow cannot use them."""
def main():
...
if num_qubits > backend.num_qubits:
raise InputError(
code="4500",
message="The circuit has more qubits than the backend.",
details={"circuit_qubits": num_qubits, "backend_qubits": backend.num_qubits},
)
...
try:
main()
except Exception as error:
send_error(
code=getattr(error, "code", "1"),
message=getattr(error, "message", repr(error)),
exception=type(error).__name__,
args=getattr(error, "details", None),
)
raise- Raise the error again. The final
raisekeeps the traceback in the logs and ends the job with theERRORstatus. - Subclass
ServerlessErrorto name a group of errors. The handler sends the name of the class, such asInputError, as theexceptionfield. - Other exceptions also reach the handler. They get the code
1, and theirrepr()becomes the message, such asValueError('bad shots').
Report a problem and continue
send_error() does not stop the workflow or change its status, so you can also report problems that the workflow recovers from, such as each circuit that it skips:
valid_circuits = []
for index, circuit in enumerate(circuits):
if circuit.num_qubits > backend.num_qubits:
send_error(
code="4501",
message="Skipped a circuit that is wider than the backend.",
exception="InputError",
args={"circuit_index": index},
)
continue
valid_circuits.append(circuit)job.events(type="ERROR") returns every error that the workflow reported, including the errors of a job that ended with the DONE status. See Find the cause of an error.
- Pass numbers and strings in
args.error_message()shows only the first item of a list. - Check the return value. If a workflow cannot store the error, for example because the code runs outside a job,
send_error()writes the error to the logs and returnsFalse.
Clean up when a job is canceled
When you cancel a job, the workflow sends SIGTERM to your entrypoint, uploads the final logs, and shuts down the environment. Without a handler, Python stops immediately, and finally blocks do not run. The workflow also tries to cancel the QPU jobs and sessions that the workflow started with get_runtime_service(). See Cancel linked QPU jobs.
To keep partial work, register a handler for SIGTERM. Your local notebook then gets the partial result from a canceled job:
job.cancel()
print(job.result()) # {'status': 'canceled', 'partial_results': [...]}To enable this, add the handler at the start of your entrypoint:
import signal
import sys
from qiskit_serverless import save_result
partial_results = []
def handle_sigterm(signum, frame):
save_result({"status": "canceled", "partial_results": partial_results})
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)sys.exit() in the handler raises SystemExit in the code that is running, so finally blocks run before the script ends. The job still ends with the CANCELED status.
Keep the handler short, because the workflow shuts down the environment soon after SIGTERM. For long runs, also save checkpoints, so that a later run can resume after a job fails or reaches the 24-hour limit. See Best practice: checkpoint long jobs.
Next steps
- Run a managed workflow to start a job and read its status, logs, and result.
- Manage files across runs to save checkpoints that a later run can resume from.
- Choose a compute profile to run your workflow with more CPU, memory, or GPU resources.