Batch Exporting 300 DPI PDFs from QGIS with Python

Batch-exporting 300 DPI PDFs from QGIS with Python means driving QgsLayoutExporter inside a headless PyQGIS process: bootstrap a GUI-less QgsApplication, load the .qgz project, enable the print layout’s atlas over its coverage layer, and export each feature with PdfExportSettings.dpi=300 and forceVectorOutput=True so text and lines stay scalable while any raster content is sampled at print resolution. A per-sheet validation pass then reopens every PDF and confirms the embedded resolution and page dimensions before the batch is released to the press.

Core Algorithm and Workflow

A print PDF is not a single-resolution image, so “300 DPI” describes two separate guarantees. Vector elements — labels, strokes, symbol geometry — must remain resolution-independent, which forceVectorOutput=True enforces. Raster elements — a hillshade basemap, an aerial tile, any layer flattened for a blend mode — must be sampled at 300 DPI, which the dpi field controls. The batch workflow below produces both from a single atlas pass and verifies them afterward.

Batch atlas export and validation flow Diagram: a coverage layer feeds the atlas iterator, which advances feature by feature through QgsLayoutExporter configured for 300 DPI vector output; each iteration writes one PDF, and every PDF passes through a DPI and dimension validation gate before the batch is released. Coverage layer Atlas iterate QgsLayout- Exporter dpi=300, vector sheet_01.pdf sheet_02.pdf validate DPI + page size next feature
  1. Bootstrap headless. Construct QgsApplication(None, False) with the GUI flag off, point setPrefixPath at the install, and call initQgis(). On a server, QT_QPA_PLATFORM=offscreen keeps Qt from probing for a display.
  2. Load project and layout. Read the .qgz with QgsProject.instance().read(path) and fetch the print layout by name from layoutManager().
  3. Configure export settings. Set dpi=300, forceVectorOutput=True, and rasterizeWholeImage=False so vectors stay crisp and only genuinely raster content is sampled at 300 DPI. This is the same resolution contract discussed across DPI and Resolution Management.
  4. Iterate the atlas. Enable the atlas on its coverage layer and let exportToPdfs write one file per feature — every polygon in the coverage layer becomes one map sheet.
  5. Validate. Reopen each PDF, assert 300 DPI and correct page dimensions, and only then release the batch.
Which QGIS export settings actually change the output resolution Three grouped settings. The layout DPI field sets the resolution of everything rasterised. The rasterise-whole-layout flag converts vector text and lines to pixels and is the setting that silently destroys sharp type. The image tile size affects memory during export but not the result. Each group is marked with whether it changes the file, changes only memory, or changes quality irreversibly. layout DPI — sets the resolution of every rasterised element set it on the layout, not on the exporter, or the layout value wins silently rasterise whole layout — irreversible quality loss turns live text into pixels; leave off unless a blend mode forces it image tile size — memory only changes peak RAM during export; the output file is identical Only the first two are worth putting in a build profile. The third is host tuning and should not travel with the map definition.
The rasterise flag is the one that turns a crisp PDF into an image of a map. It defaults on in several export paths, which is why an explicit setting belongs in the script rather than in the project file.

Production-Ready Python Implementation

The script below is a complete, headless exporter. It runs under the QGIS Python interpreter (pinned to QGIS 3.34 LTR, PyQGIS API) and uses pikepdf (9.x) to read back each sheet’s page geometry for validation. Run it with the bundled interpreter — for example qgis_process environments or python3 with PYTHONPATH pointing at the QGIS python directory — so qgis.core resolves.

import os
import sys
from pathlib import Path

# Headless Qt: never attempt to open a display on a server.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")

from qgis.core import (
    QgsApplication,
    QgsProject,
    QgsLayoutExporter,
)


def start_qgis(prefix_path: str = "/usr") -> QgsApplication:
    """Boot a GUI-less QgsApplication suitable for a server or CI runner."""
    QgsApplication.setPrefixPath(prefix_path, True)
    qgs = QgsApplication([], False)  # second arg False => no GUI
    qgs.initQgis()
    return qgs


def load_layout(project_path: str, layout_name: str):
    """Read a .qgz project and return the named print layout."""
    project = QgsProject.instance()
    if not project.read(project_path):
        raise RuntimeError(f"Could not read project: {project_path}")
    layout = project.layoutManager().layoutByName(layout_name)
    if layout is None:
        names = [l.name() for l in project.layoutManager().layouts()]
        raise RuntimeError(
            f"Layout '{layout_name}' not found. Available: {names}"
        )
    return layout


def pdf_settings(dpi: int = 300) -> QgsLayoutExporter.PdfExportSettings:
    """Print-ready vector PDF settings: crisp text, raster sampled at target DPI."""
    settings = QgsLayoutExporter.PdfExportSettings()
    settings.dpi = dpi
    settings.forceVectorOutput = True      # keep labels/strokes as vectors
    settings.rasterizeWholeImage = False   # do not flatten the whole page
    settings.simplifyGeometries = False    # no lossy geometry reduction
    settings.appendGeoreference = True     # embed a GeoPDF reference grid
    return settings


def export_atlas(layout, out_dir: str, dpi: int = 300) -> list[Path]:
    """Iterate the layout's atlas and write one 300 DPI PDF per feature."""
    Path(out_dir).mkdir(parents=True, exist_ok=True)
    atlas = layout.atlas()
    if not atlas.enabled():
        atlas.setEnabled(True)
    if atlas.coverageLayer() is None:
        raise RuntimeError("Atlas has no coverage layer configured.")

    exporter = QgsLayoutExporter(layout)
    base_path = str(Path(out_dir) / "sheet")  # atlas filename expr appends the rest

    result, error = exporter.exportToPdfs(atlas, base_path, pdf_settings(dpi))
    if result != QgsLayoutExporter.Success:
        raise RuntimeError(f"Atlas export failed ({result}): {error}")

    return sorted(Path(out_dir).glob("*.pdf"))


def validate_pdf(pdf_path: Path, layout, dpi: int = 300, tol_mm: float = 0.5) -> None:
    """Assert page size matches the layout paper and DPI metadata is intact.

    A vector PDF has no single raster DPI, so we validate the *geometry*
    contract: page dimensions must equal the layout's paper size to within
    tol_mm. Any embedded raster image is checked against the target DPI.
    """
    import pikepdf

    # Expected paper size in millimetres from the first page item.
    page = layout.pageCollection().page(0)
    exp_w = page.pageSize().width()
    exp_h = page.pageSize().height()

    with pikepdf.open(pdf_path) as pdf:
        mbox = pdf.pages[0].MediaBox
        # PDF units are points (1/72 inch); convert to millimetres.
        pts_to_mm = 25.4 / 72.0
        got_w = float(mbox[2] - mbox[0]) * pts_to_mm
        got_h = float(mbox[3] - mbox[1]) * pts_to_mm

        if abs(got_w - exp_w) > tol_mm or abs(got_h - exp_h) > tol_mm:
            raise AssertionError(
                f"{pdf_path.name}: page {got_w:.1f}x{got_h:.1f} mm "
                f"!= layout {exp_w:.1f}x{exp_h:.1f} mm"
            )

        # For any embedded raster XObject, verify effective sampling >= target.
        for name, xobj in pdf.pages[0].images.items():
            px_w = int(xobj.Width)
            placed_in = got_w / 25.4  # worst case: image spans full width
            eff_dpi = px_w / placed_in if placed_in else 0
            if eff_dpi + 1 < dpi:  # +1 absorbs rounding
                raise AssertionError(
                    f"{pdf_path.name}: raster {name} ~{eff_dpi:.0f} DPI < {dpi}"
                )
    print(f"OK  {pdf_path.name}  {got_w:.1f}x{got_h:.1f} mm")


def main() -> int:
    project_path = "atlas_project.qgz"
    layout_name = "print_sheets"
    out_dir = "export_300dpi"
    dpi = 300

    qgs = start_qgis()
    try:
        layout = load_layout(project_path, layout_name)
        pdfs = export_atlas(layout, out_dir, dpi)
        for pdf_path in pdfs:
            validate_pdf(pdf_path, layout, dpi)
        print(f"Exported and validated {len(pdfs)} sheet(s) into {out_dir}/")
        return 0
    finally:
        qgs.exitQgis()


if __name__ == "__main__":
    sys.exit(main())

The exportToPdfs (plural) call is the single most important line: it advances the atlas over every feature in the coverage layer and emits one file per sheet, naming each from the atlas filename expression configured in the project. For a single merged document, swap it for exporter.exportToPdf(atlas.layout(), path, settings) with the atlas enabled.

Performance Tuning and Cartographic Best Practices

  • Keep forceVectorOutput=True unless a layer forces rasterization. Vector text and strokes are resolution-independent and add negligible file weight. The dpi value only bites on raster layers and flattened blend regions — pairing it with forceVectorOutput gives the smallest print-ready file. The same trade-off drives High-Resolution Vector Export.
  • Rasterize selectively, not wholesale. A single layer with an opacity or multiply blend mode can trigger whole-page rasterization, silently dropping every other layer to the raster dpi. Isolate such effects onto their own layer or pre-flatten them, and leave rasterizeWholeImage=False.
  • Boot QGIS once, export many. initQgis() and project read are the expensive steps. Amortize them by looping multiple projects or layouts inside one QgsApplication lifetime rather than launching a fresh interpreter per sheet — the pattern that scales cleanly into Batch Queue Orchestration.
  • Validate dimensions, not just a DPI flag. Because a vector PDF has no global DPI, the reliable print contract is page geometry: assert the MediaBox equals the layout paper size within a sub-millimetre tolerance, and check any embedded raster’s effective sampling separately, as the script above does.
  • Convert to CMYK downstream, not in QGIS. QGIS emits RGB PDFs. If the press needs CMYK, run the validated sheets through a dedicated color pass rather than trusting an implicit conversion — see Color Profile and CMYK Conversion.
The state that leaks between atlas features A loop over atlas features with four state items listed beside it. Map item extent and the atlas filter expression are refreshed per feature by the atlas engine. Layer visibility toggled by a script and any manually set scale are not, so they carry over from the previous sheet. The fix shown is to snapshot the layout state before the loop and restore it at the top of each iteration. for feature in atlas refreshed by the atlas engine · map item extent · atlas filter expression not refreshed — leaks forward · layer visibility set by script · scale pinned by hand · legend filter state Snapshot the layout before the loop; restore it at the top of every iteration. Sheet 1 is always correct, which is why this is usually found by a reader, not by a test.
Atlas bugs are order-dependent: the first sheet is right and every later one inherits the last one's state. Restoring a snapshot per iteration makes each sheet independent of the ones before it.

Integration and Next Steps

The exporter returns a sorted list of validated Path objects, which makes it easy to drop into a larger build. Wrap main() as a Celery task or a subprocess step and fan the coverage layer out across workers so each renders a slice of the atlas; because each QgsApplication process is independent, the only shared state is the output directory. Store the validated PDFs as build artifacts and let a downstream job assemble them into a single deliverable or hand them to the print queue.

When a sheet fails validation, the mismatch is almost always a resolution or unit problem rather than a QGIS bug — the same class of failure that appears when a headless rasterizer and its writer disagree on DPI, covered in Debugging DPI Mismatch in Headless Matplotlib Exports. Treat the validation gate as the source of truth: if the MediaBox disagrees with the layout paper size, fix the layout or the atlas page settings before touching the export code.


Frequently Asked Questions

Why does my PDF export ignore dpi=300 and stay at 96 DPI?

A vector PDF has no single global DPI; dpi only governs how rasterized elements are sampled. With forceVectorOutput=True and no layer forcing rasterization, text and geometry are resolution-independent — there is nothing at 96 DPI to find. When you do see 96, a raster layer or a blend/opacity effect triggered whole-page rasterization at the layout’s preview resolution. Set PdfExportSettings.dpi=300 and rasterizeWholeImage=False, and audit each layer’s blend mode.

How do I export one PDF per atlas feature instead of a single merged file?

Call QgsLayoutExporter.exportToPdfs(atlas, base_path, settings) — the plural method writes one file per atlas feature, naming each from the atlas filename expression. The singular exportToPdf with an active atlas produces one multi-page document instead. For fully manual control, drive atlas.first() / atlas.next() yourself and call exportToPdf with a distinct path on each iteration.

Do I need a running X server to export layouts headlessly?

No. QgsApplication([], False) initializes without a GUI, and setting QT_QPA_PLATFORM=offscreen tells Qt not to open a display. Layout rendering runs through the QPainter/QPrinter pipeline, which needs no windowing system once the offscreen platform plugin is active — which is exactly what makes this safe to run in CI or a container.


Back to DPI and Resolution Management