Packaging Offline Field Projects with QFieldSync

When a survey team drives out to a scheduled monument with no mobile coverage, the tablet has to hold everything: the editable layers, the attribute forms, a usable base map, and nothing the collector should not touch. Doing this by hand through the QGIS interface is slow and unrepeatable across a dozen devices and a new project every fortnight. This guide shows how to package an offline QField project entirely from PyQGIS using the qfieldsync core classes, so the same command produces an identical, area-clipped, base-mapped device project every time. It is one of the four practical guides under Participatory GIS and Mobile Field Sync.

QFieldSync offline packaging steps A master QGIS project is filtered, given a base map, converted offline, and exported to the device. Offline packaging pipeline Master .qgz read + filter Base map render to MBTiles Offline convert clip to AOI Export to device folder

Context & When to Use

Reach for headless QFieldSync packaging when you provision more than one device, re-cut projects frequently, or need packaging to run as part of an automated field-prep job rather than a manual click-through. The trade-off is deliberate: an offline copy is a snapshot. The moment you convert, the device diverges from the master, and every hour in the field widens the gap you must reconcile later. Keep the area of interest tight (clip to the trench block, not the county), keep editable layers to the minimum the collector needs, and set read-only or hidden on everything else. A smaller package syncs faster, conflicts less, and gives volunteers fewer ways to corrupt reference data. If your team stays connected and edits a shared server layer live, you do not need offline packaging at all — that is a live-database pattern, covered in comparing GeoPackage and PostGIS for field-to-archive handoff.

Implementation

The routine below reads a master project, restricts the collector’s editable layer, renders a raster base map so the tablet is usable with no signal, and runs the offline conversion clipped to an area-of-interest polygon. Run it inside the QGIS 3.34 Python environment (via qgis_process, a standalone PyQGIS script with the application initialised, or the QGIS Python console).

# environment (not pip-installable standalone — ships with QGIS)
# QGIS==3.34 LTR
# qfieldsync==4.8.0
# PyQGIS 3.34 / qfieldsync 4.8.0
import os
from qgis.core import (
    QgsProject, QgsCoordinateReferenceSystem, QgsRectangle,
    QgsVectorLayer, QgsMapLayer,
)
from qfieldsync.core.offline_converter import OfflineConverter
from qfieldsync.core.project import ProjectConfiguration
from qfieldsync.libqfieldsync.layer import LayerSource, SyncAction

MASTER = "/data/site/master.qgz"
EXPORT_DIR = "/data/field_packages/trench_A"
TARGET_CRS = QgsCoordinateReferenceSystem("EPSG:27700")  # substitute your site's EPSG
# area of interest for trench block A, in the target CRS (metres)
AOI = QgsRectangle(451200, 213400, 451260, 213470)

def package_offline(master=MASTER, export_dir=EXPORT_DIR, aoi=AOI):
    project = QgsProject.instance()
    if not project.read(master):
        raise RuntimeError(f"Could not read master project: {master}")

    # 1. Per-layer sync policy: only the finds layer is editable offline,
    #    reference layers are copied read-only, heavy rasters stay as base map.
    for layer in project.mapLayers().values():
        src = LayerSource(layer)
        if layer.name() == "finds":
            src.action = SyncAction.OFFLINE          # editable, changes tracked
        elif layer.type() == QgsMapLayer.VectorLayer:
            src.action = SyncAction.COPY             # travels with project, read-only
        else:
            src.action = SyncAction.KEEP_EXISTENT    # keep as-is / base map source
        src.apply()

    # 2. Project-level offline configuration.
    cfg = ProjectConfiguration(project)
    cfg.offline_copy_only_aoi = True                 # clip vector copies to the AOI
    cfg.create_base_map = True                       # bake a base map into the package
    cfg.base_map_type = "mbtiles"                    # single-file raster tiles for offline
    cfg.base_map_tile_size = 1024
    cfg.base_map_mupp = 0.10                         # ~0.1 m/px map units per pixel

    os.makedirs(export_dir, exist_ok=True)

    # 3. Run the conversion. The AOI must be expressed as WKT in the project CRS.
    converter = OfflineConverter(
        project,
        export_dir,
        area_of_interest=aoi.asWktPolygon(),
        area_of_interest_crs=TARGET_CRS.authid(),    # "EPSG:27700"
        offline_editing=None,                        # QFieldSync builds its own instance
    )
    converter.convert(reload_original_project=False)
    exported = os.path.join(export_dir, os.path.basename(master))
    print(f"Packaged offline project at: {exported}")
    return exported

if __name__ == "__main__":
    package_offline()

Copy the resulting export_dir to the device (USB, MDM push, or a synced folder). Because the finds layer was marked SyncAction.OFFLINE, QField records every edit against the packaged baseline, ready for a later qfieldsync reimport that produces a clean change set.

Field note. Set the base-map resolution to what the survey actually needs, not the maximum the source raster offers. A 0.1 m/px orthophoto over a full trench block can produce an MBTiles file larger than the device's free storage, and QField fails silently at load with a blank canvas — collectors then invent positions from memory. Package at the coarsest resolution that still lets a recorder locate a feature, and test-open the file on a real device before deployment.

Verification

Confirm the package is complete and correctly projected before it leaves the office:

# GDAL/OGR 3.8.4 — inspect the packaged GeoPackage
ogrinfo -so /data/field_packages/trench_A/master_qfield.gpkg finds

Expected: the layer summary reports Geometry: Point, SRS: EPSG:27700 (matching your site EPSG), a non-zero Feature Count for existing finds, and the UUID sync column present in the field list. Also assert the base map exists and is non-trivial:

test -s /data/field_packages/trench_A/*.mbtiles && echo "base map OK" || echo "MISSING base map"

A base map file of only a few kilobytes usually means the render extent did not intersect the AOI — re-check that AOI is expressed in the same CRS as the project.

Common Errors & Fixes

  • Layer 'finds' has no primary key, offline editing is not possible — OGR needs a stable feature id. Ensure the source layer exposes the UUID column as its feature id (or an integer PK) before conversion; a view without a key cannot be packaged.
  • QgsProcessingException: Could not load source layer for base mapcreate_base_map = True but no visible raster/renderable layer intersects the AOI. Add the orthophoto to the project and confirm it is checked on in the layer tree, since QFieldSync renders only visible layers.
  • Exported points land offshore in QField — the AOI or a layer was labelled with the wrong CRS, so degrees were treated as metres (or vice versa). Verify with ogrinfo -so; every packaged vector layer and the AOI must share the site EPSG.

Part of Heritage GIS Architecture & Fundamentals.