Skip to main content

Overview

Deltas are the fundamental unit of change in QFieldCloud’s synchronization system. Each delta represents a single edit operation (create, update, or delete) performed in QField mobile app. The delta system enables offline editing with robust conflict detection and resolution.

Delta Model

The Delta model tracks individual edit operations:
class Delta(models.Model):
    id = models.UUIDField(primary_key=True, default=uuid.uuid4)
    deltafile_id = models.UUIDField(db_index=True)
    client_id = models.UUIDField(db_index=True)
    project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="deltas")
    content = JSONField()
    last_status = models.CharField(choices=Status.choices, default=Status.PENDING)
    last_feedback = JSONField(null=True)
    last_modified_pk = models.TextField(null=True)
    last_apply_attempt_at = models.DateTimeField(null=True)
    last_apply_attempt_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True)
    created_by = models.ForeignKey(User, on_delete=models.CASCADE, related_name="uploaded_deltas")
    old_geom = models.GeometryField(null=True, srid=4326, dim=4)
    new_geom = models.GeometryField(null=True, srid=4326, dim=4)
See core/models.py:2103.

Delta Methods

Three types of operations:
class Method(str, Enum):
    Create = "create"
    Delete = "delete"
    Patch = "patch"

Create

Adds a new feature to a layer:
{
  "method": "create",
  "localLayerId": "points_layer",
  "new": {
    "geometry": {
      "type": "Point",
      "coordinates": [7.5, 46.5]
    },
    "attributes": {
      "name": "New Survey Point",
      "date": "2024-01-15"
    }
  }
}

Patch

Modifies an existing feature’s attributes or geometry:
{
  "method": "patch",
  "localLayerId": "points_layer",
  "localPk": "123",
  "old": {
    "attributes": {
      "status": "pending"
    }
  },
  "new": {
    "attributes": {
      "status": "completed"
    }
  }
}

Delete

Removes a feature from a layer:
{
  "method": "delete",
  "localLayerId": "points_layer",
  "localPk": "123",
  "old": {
    "geometry": {...},
    "attributes": {...}
  }
}

Delta Status

Deltas progress through various status states:
class Status(models.TextChoices):
    PENDING = "pending", "Pending"
    STARTED = "started", "Started"
    APPLIED = "applied", "Applied"
    CONFLICT = "conflict", "Conflict"
    NOT_APPLIED = "not_applied", "Not_applied"
    ERROR = "error", "Error"
    IGNORED = "ignored", "Ignored"
    UNPERMITTED = "unpermitted", "Unpermitted"

Status Flow

  1. PENDING - Delta uploaded, waiting for apply job
  2. STARTED - Apply job is processing this delta
  3. APPLIED - Successfully applied to dataset
  4. CONFLICT - Conflicting change detected
  5. ERROR - Application failed due to error
  6. UNPERMITTED - User lacks permission to apply
  7. IGNORED - Explicitly skipped during application
  8. NOT_APPLIED - Failed to apply for other reasons
See core/models.py:2109.

Deltafile Format

Structure

A deltafile is a JSON document containing multiple deltas:
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "project": "123e4567-e89b-12d3-a456-426614174000",
  "version": "1.0",
  "created": "2024-01-15T10:30:00Z",
  "client": "QField 3.0",
  "clientId": "device-uuid-here",
  "deltas": [
    {
      "uuid": "delta-uuid-1",
      "clientId": "device-uuid-here",
      "localLayerId": "survey_points",
      "localLayerName": "Survey Points",
      "method": "create",
      "new": {
        "geometry": {...},
        "attributes": {...}
      }
    },
    {
      "uuid": "delta-uuid-2",
      "clientId": "device-uuid-here",
      "localLayerId": "survey_lines",
      "method": "patch",
      "localPk": "456",
      "old": {...},
      "new": {...}
    }
  ]
}

Validation

Deltafiles are validated against JSON schema:
utils.get_deltafile_schema_validator().validate(deltafile_json)
Validation checks:
  • Required fields present
  • Valid UUID formats
  • Supported delta methods
  • Valid geometry (if present)
  • Project ID matches request
See core/views/deltas_views.py:78.

Uploading Deltas

API Endpoint

POST /api/v1/deltas/{projectid}/

Request

Multipart form data with deltafile:
curl -X POST https://app.qfield.cloud/api/v1/deltas/{projectid}/ \
  -H "Authorization: Token YOUR_API_TOKEN" \
  -F "[email protected]"

Upload Process

  1. Receive Deltafile
    • Extract from multipart request
    • Parse JSON content
  2. Validation
    if deltafile_projectid != str(projectid):
        raise exceptions.DeltafileValidationError()
    
    if not project_obj.has_the_qgis_file:
        raise exceptions.NoQGISProjectError()
    
  3. Duplicate Detection
    delta_ids = sorted([str(delta["uuid"]) for delta in deltas])
    existing_delta_ids = [
        str(v)
        for v in Delta.objects.filter(id__in=delta_ids)
        .order_by("id")
        .values_list("id", flat=True)
    ]
    
  4. Create Delta Objects
    delta_obj = Delta(
        id=delta["uuid"],
        deltafile_id=deltafile_id,
        project=project_obj,
        content=delta,
        client_id=delta["clientId"],
        created_by=request.user,
    )
    
  5. Permission Check
    if not permissions_utils.can_create_delta(request.user, delta_obj):
        delta_obj.last_status = Delta.Status.UNPERMITTED
    else:
        delta_obj.last_status = Delta.Status.PENDING
    
  6. Auto-Apply
    if created_deltas:
        jobs.apply_deltas(
            project_obj,
            request.user,
            project_obj.the_qgis_file_name,
            project_obj.overwrite_conflicts,
        )
    
See core/views/deltas_views.py:67.

Apply Jobs

ApplyJob Model

class ApplyJob(Job):
    deltas_to_apply = models.ManyToManyField(
        to=Delta,
        through="ApplyJobDelta",
    )
    overwrite_conflicts = models.BooleanField(
        help_text="Automatically overwrite conflicts while applying deltas"
    )
Apply jobs process batches of pending deltas. See core/models.py:2403.

ApplyJobDelta Through Model

Tracks status of each delta within a job:
class ApplyJobDelta(models.Model):
    apply_job = models.ForeignKey(ApplyJob, on_delete=models.CASCADE)
    delta = models.ForeignKey(Delta, on_delete=models.CASCADE)
    status = models.CharField(choices=Delta.Status.choices, default=Delta.Status.PENDING)
    feedback = JSONField(null=True)
    modified_pk = models.TextField(null=True)
See core/models.py:2426.

Apply Process

  1. Job Creation
    • Collect pending deltas
    • Create ApplyJob instance
    • Link deltas via ApplyJobDelta
  2. Worker Execution
    • QGIS worker container starts
    • Opens project file
    • Processes deltas sequentially
  3. Per-Delta Application
    • Validate delta structure
    • Check for conflicts
    • Apply to layer
    • Record outcome
  4. Completion
    • Update delta statuses
    • Save modified project files
    • Update project timestamp

Conflict Detection

What Causes Conflicts?

Conflicts occur when:
  1. Concurrent Edits
    • Same feature edited on desktop and mobile
    • Multiple mobile devices edit same feature
  2. Stale Data
    • Mobile package outdated
    • Desktop changes not yet packaged
  3. Deleted Features
    • Feature deleted on desktop, edited on mobile
    • Feature edited on desktop, deleted on mobile

Conflict Detection Logic

During delta application:
# For PATCH deltas
if delta.method == "patch":
    current_feature = layer.getFeature(pk)
    
    # Check if old values match current
    if delta.old_attributes != current_feature.attributes():
        # Conflict detected!
        delta.last_status = Delta.Status.CONFLICT
Conflicts are detected by comparing:
  • Delta’s old values
  • Current feature state
  • If different, someone else made changes

Conflict Resolution

Automatic Resolution

When project.overwrite_conflicts=True:
if apply_job.overwrite_conflicts:
    # Apply delta regardless of conflicts
    # Latest change wins
    apply_delta_to_feature(delta)
    delta.last_status = Delta.Status.APPLIED
Best for:
  • Field data collection workflows
  • Single editor per feature
  • Time-based priority (latest wins)

Manual Resolution

When project.overwrite_conflicts=False:
if conflict_detected and not apply_job.overwrite_conflicts:
    delta.last_status = Delta.Status.CONFLICT
    delta.last_feedback = {
        "conflict_reason": "Feature modified since package creation",
        "old_value": delta.old_attributes,
        "current_value": current_feature.attributes(),
        "new_value": delta.new_attributes,
    }
Project manager must:
  1. Review conflicting deltas
  2. Decide which changes to keep
  3. Manually resolve via API or UI
  4. Re-trigger apply job

Resolution Strategies

  1. Last Write Wins - Automatic, simple
  2. Manual Review - Time-consuming, precise
  3. Attribute-Level - Merge non-conflicting attributes
  4. Time-Based - Prefer changes from specific time range

Delta Querying

List All Deltas

GET /api/v1/deltas/{projectid}/

Filter by Deltafile

GET /api/v1/deltas/{projectid}/deltafiles/{deltafileid}/

Status Summary

Get delta count by status:
@staticmethod
def get_status_summary(filters={}):
    rows = (
        Delta.objects.filter(**filters)
        .values("last_status")
        .annotate(count=Count("last_status"))
        .order_by()
    )
    
    counts = {}
    for status, _name in Delta.Status.choices:
        counts[status] = rows_as_dict.get(status, 0)
    
    return counts
See core/models.py:2161.

Geometry Handling

Geometry Fields

Deltas store geometries for spatial operations:
old_geom = models.GeometryField(null=True, srid=4326, dim=4)
new_geom = models.GeometryField(null=True, srid=4326, dim=4)
  • SRID 4326 - WGS84 coordinate system
  • 4D - Supports X, Y, Z, M dimensions

Use Cases

  • Spatial queries on deltas
  • Visualization of changes
  • Conflict detection based on location
  • Analysis of field coverage

Faulty Deltafiles

When deltafile upload fails:
class FaultyDeltaFile(models.Model):
    deltafile_id = models.UUIDField(null=True)
    deltafile = DynamicStorageFileField(upload_to=get_faulty_deltafile_upload_to)
    project = models.ForeignKey(Project, on_delete=models.SET_NULL, null=True)
    user_agent = models.CharField(max_length=255)
    traceback = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)
Faulty deltafiles:
  • Preserved for debugging
  • Include error traceback
  • Track client information
  • Help identify systematic issues
See core/models.py:2679 and core/views/deltas_views.py:162.

Best Practices

Field Workflow

  1. Sync Frequently - Upload deltas at least daily
  2. Check Status - Monitor apply job outcomes
  3. Handle Conflicts - Review and resolve promptly
  4. Update Packages - Refresh mobile data regularly

Conflict Prevention

  1. Partition Work - Assign geographic areas to users
  2. Feature Locking - Coordinate who edits what
  3. Frequent Sync - Reduce time between updates
  4. Latest Packages - Download fresh data before fieldwork

Performance

  1. Batch Deltas - Upload in deltafiles, not individually
  2. Optimize Apply - Let automatic apply handle routine cases
  3. Monitor Jobs - Track apply job duration
  4. Clean Old Deltas - Archive applied deltas periodically

Troubleshooting

  1. Check Permissions - Verify user can create deltas
  2. Validate Schema - Ensure deltafile format correct
  3. Review Logs - Check apply job output
  4. Test Locally - Reproduce issues in QGIS

API Reference

Upload Deltafile

POST /api/v1/deltas/{projectid}/
Content-Type: multipart/form-data Field: file containing deltafile JSON

List Deltas

GET /api/v1/deltas/{projectid}/

Trigger Apply (Deprecated)

POST /api/v1/deltas/{projectid}/apply/
Note: Use jobs endpoint instead:
POST /api/v1/jobs/
{
  "project_id": "uuid",
  "type": "delta_apply"
}

Build docs developers (and LLMs) love