Log Messages to the QGIS Message Log in PyQGIS
print() goes to the Python console, which is fine while you are looking at it and useless everywhere else — inside a plugin a user is running, inside a Processing algorithm, inside a background task, on a server. QGIS has a proper logging surface: a message log with tags and severity levels, and a message bar for the things a user needs to see right now. Knowing which of the three to reach for is most of the skill.
This recipe belongs to Debugging PyQGIS Scripts. It covers writing to the message log, choosing tags and levels, using the message bar for user-facing notices, reading what QGIS itself logs, and forwarding everything into Python's logging for unattended runs.
Prerequisites
- QGIS 3.34 LTR (bundled Python 3.12) or newer.
- The Log Messages panel open (View → Panels), or the small triangle icon in the status bar's bottom-right corner.
- For the message-bar examples,
iface— available inside a plugin and in the Python console.
Write to the message log
from qgis.core import QgsMessageLog, Qgis
QgsMessageLog.logMessage("Loaded 12 480 parcels", "ParcelTools", Qgis.Info)
QgsMessageLog.logMessage("3 features had null geometry", "ParcelTools", Qgis.Warning)
QgsMessageLog.logMessage("Could not open /data/wards.gpkg", "ParcelTools", Qgis.Critical)
Breakdown: The three arguments are message, tag and level. The tag becomes a tab in the Log Messages panel, so giving your plugin its own tag separates its output from the dozens of messages QGIS produces — and makes it findable when a user sends a screenshot. The levels are Qgis.Info, Qgis.Warning, Qgis.Critical and Qgis.Success; Critical makes the log panel's status-bar indicator turn red, which is the closest thing to an alert QGIS offers without interrupting the user. Entries are timestamped automatically.
Keep messages specific and countable. "Loaded 12 480 parcels" tells a future reader whether the run was normal; "done" tells them nothing, and two consecutive runs of "done" cannot be compared.
Choose the right level
| Level | Use it for | The reader's reaction |
|---|---|---|
Qgis.Info | Normal progress, counts, chosen parameters | Reads it only when investigating |
Qgis.Success | A completed operation worth confirming | Reassured |
Qgis.Warning | Something recoverable and unexpected — skipped rows, a fallback used | Investigates later |
Qgis.Critical | The operation failed and produced no result | Acts now |
Breakdown: The discipline that makes levels useful is refusing to inflate them. A warning for something that happens on every run trains everyone to ignore warnings, and then the one that mattered is invisible too. If a condition is normal, log it as info; if it is genuinely unexpected, warn once per run rather than once per feature.
Speak to the user with the message bar
The log is for diagnosis. When the user needs to know something now, use the message bar.
from qgis.core import Qgis
from qgis.utils import iface
iface.messageBar().pushMessage(
"Parcels",
"12 480 features loaded; 3 had null geometry",
level=Qgis.Warning,
duration=8,
)
Breakdown: The first argument is a bold prefix and the second the body. duration is in seconds; passing 0 makes the message persist until dismissed, which is right for an error and rude for a success. The message bar is modeless — it does not steal focus or block work — which is exactly why it should be preferred over QMessageBox for anything that is not a question. One bar message per user action is a good ceiling; the details belong in the log, and pointing at the log in the bar text is a reasonable way to connect them.
Read what QGIS is telling you
The most useful messages in the log are usually not yours. Providers, algorithms and plugins all write there, and the answer to "why did that layer fail to load" is frequently already sitting in the panel.
from qgis.core import QgsApplication
def on_message(message, tag, level):
if level >= Qgis.Warning:
print(f"[{tag}] {message}")
QgsApplication.messageLog().messageReceived.connect(on_message)
Breakdown: Connecting to messageReceived gives you every entry as it is written, including QGIS's own. Filtering by level keeps the noise down while you are hunting a specific failure. This is also the hook that makes headless runs observable — replacing the print with a call into Python's logging sends provider errors to the same file as everything else, which is the pattern developed in Handle Errors and Logging in Unattended Scripts.
Log from a Processing algorithm
Inside a custom algorithm the feedback object is the correct channel, because it routes to whichever front end is running — the dialog, the console, or qgis_process.
def processAlgorithm(self, parameters, context, feedback):
feedback.pushInfo(f"Processing {source.featureCount()} features")
if skipped:
feedback.reportError(f"{skipped} features had invalid geometry", fatalError=False)
return {"OUTPUT": sink_id}
Breakdown: pushInfo() and reportError() reach the user wherever the algorithm was launched from, which QgsMessageLog does not — a message logged to the panel during a qgis_process run may never be seen. fatalError=False reports a problem without aborting, which suits a per-feature complaint. Writing an algorithm this way is what makes it usable from the toolbox, from a model and from the command line, as covered in Write a Custom Processing Algorithm in PyQGIS.
Write messages that can be searched later
A log is read under pressure, usually months after it was written, often by someone else. Two habits make that reading fast.
Put the numbers in, and keep the shape constant. A message built as prose varies from run to run and cannot be compared; a message built from fixed keys and changing values can be grepped, diffed and even parsed.
def log_summary(tag, **fields):
body = " ".join(f"{key}={value}" for key, value in sorted(fields.items()))
QgsMessageLog.logMessage(body, tag, Qgis.Info)
log_summary("ParcelTools", stage="load", source="parcels", features=12480, seconds=3.4)
log_summary("ParcelTools", stage="buffer", distance=25, features=12480, seconds=41.2)
Breakdown: Sorting the keys means the same fields always appear in the same order, so two runs line up when diffed. Using key=value pairs rather than a sentence makes grep 'stage=buffer' | awk viable, which is the difference between answering "did this get slower?" in ten seconds and re-running the job to find out. Keeping the tag constant per plugin, rather than varying it per operation, keeps everything in one tab of the log panel where it can be read as a sequence.
Log the decision, not just the outcome. "Skipped 3 features" prompts an immediate second question; "skipped=3 reason=null_geometry ids=104,220,881" answers it. Where the identifiers are few, include them — a message that lets someone open exactly the three offending features has saved a round trip that would otherwise take a morning.
QGIS version compatibility
The examples target QGIS 3.34 LTR (Python 3.12).
| QGIS version | Python | Notes |
|---|---|---|
| 3.22 LTR | 3.9 | QgsMessageLog.logMessage and levels identical. |
| 3.28 LTR | 3.9 | Adds notifyUser argument to control the status-bar indicator. |
| 3.34 LTR | 3.12 | Baseline for this page. |
| 3.40 / 3.44 | 3.12 | Qgis.MessageLevel members are scoped (Qgis.MessageLevel.Warning); the short spelling still resolves. |
Troubleshooting
- Nothing appears in the panel. The tag has its own tab — check the tab strip along the bottom of the Log Messages panel rather than only the General tab.
print()output has vanished. The code is not running in the console. Switch toQgsMessageLog.- The message bar notice disappears too quickly. Raise
duration, or pass0for anything the user must acknowledge. ifaceis not defined. You are outside the QGIS GUI. There is no message bar in a headless run; log instead.- The log fills with one message per feature. Aggregate: count the occurrences and log once at the end with the total.
- Messages from a background task never appear. They do, but
QgsMessageLogcalls from a worker thread are queued; if the task crashed before returning, the queue may not have flushed. Log fromfinished()where possible.
Conclusion
Use QgsMessageLog.logMessage() with your own tag and an honest level for anything diagnostic, iface.messageBar() for the one thing the user needs to know, and a Processing feedback object inside algorithms. Connect to messageReceived when you need to see what QGIS itself is reporting, or to forward everything into Python logging for a run nobody is watching.
Frequently Asked Questions
Where is the log stored on disk? It is not — the panel is in-memory and cleared when QGIS closes. For a persistent record, forward the messages into Python logging and write them to a file.
Can I clear the log from Python? Not directly; the panel provides a clear action in its interface. A plugin that needs a clean slate should use its own tag so its entries are separable anyway.
Should a plugin log on every action? Log the ones with consequences — files written, features changed, parameters chosen — at info level. Skip narration of trivial steps; a log nobody can skim is a log nobody reads.
How do I show a progress message that updates? The message bar can host widgets, including a progress bar. That pattern is in Show Progress and Support Cancellation in a QGIS Plugin.
Is logging slow? Not at a sane rate. It becomes slow when called once per feature over a large layer, which is a reason to aggregate rather than a reason to avoid logging.