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.

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.

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