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.
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.
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 map—create_base_map = Truebut 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.
Related
- Participatory GIS and Mobile Field Sync — the section overview and full field-to-archive loop.
- Resolving Mergin Maps Delta-Sync Conflicts — the alternative hosted sync model and its conflict handling.
- Comparing GeoPackage and PostGIS for Field-to-Archive Handoff — when an offline snapshot beats a live server layer.
- Merging Field Audit Trails into the Master Database — what happens to the change set after reimport.