DEV Community

137Foundry
137Foundry

Posted on

How to Version Job Payloads So Schema Changes Don't Break In-Flight Work

A deploy that changes a job payload's shape while old-format jobs are still sitting in the queue is a quiet way to lose work. The consumer expects the new fields, the old jobs don't have them, and depending on how defensively the handler was written, that either crashes outright or silently processes bad data. Here's a step-by-step way to version payloads so this stops being a risk every deploy.

Step 1: Add an explicit version field to every payload, starting now

Even if you've never versioned anything before, the first step costs nothing: add a schema_version field set to 1 on every job payload going forward. Jobs enqueued before this change simply won't have the field, which the consumer can treat as an implicit version zero. This single field is what makes every later step possible.

payload = {
    "schema_version": 1,
    "order_id": order.id,
    "action": "send_confirmation",
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Branch the consumer on version, not on field presence

The tempting shortcut is checking if "new_field" in payload instead of checking a version number directly. That works until a future schema change adds a field with the same name but a different meaning, or removes a field the check depended on. Branching explicitly on schema_version keeps the logic legible and makes it obvious which code path handles which era of the payload.

def process_job(payload):
    version = payload.get("schema_version", 0)
    if version == 0:
        return process_v0(payload)
    elif version == 1:
        return process_v1(payload)
    else:
        raise ValueError(f"Unknown schema_version: {version}")
Enter fullscreen mode Exit fullscreen mode

Step 3: Write an upgrade function instead of duplicating handler logic

Once you're past two versions, duplicating full handler logic per version gets unmanageable fast. A cleaner pattern is a small upgrade function per version bump that transforms an old payload shape into the next version's shape, then always processes the latest version. The handler logic itself stays written once, against the current schema.

def upgrade_v0_to_v1(payload):
    payload["schema_version"] = 1
    payload["retry_count"] = payload.get("attempts", 0)  # renamed field
    del payload["attempts"]
    return payload

def process_job(payload):
    version = payload.get("schema_version", 0)
    while version < CURRENT_VERSION:
        payload = UPGRADERS[version](payload)
        version = payload["schema_version"]
    return process_current(payload)
Enter fullscreen mode Exit fullscreen mode

Step 4: Never deploy a breaking consumer change while old-format jobs might still be in flight

The riskiest deploy pattern is shipping a consumer that only understands the new schema while jobs in the queue still use the old one. If a schema change is happening the same day as a deploy, drain the queue first, or ship the upgrade function from Step 3 in the same deploy so the new consumer can still process old payloads. Either approach avoids the gap where in-flight jobs simply fail.

Step 5: Keep the upgrade chain, don't delete old handlers immediately

It's tempting to delete process_v0 the moment nothing is enqueuing v0 payloads anymore, but a job that's been sitting in a dead-letter table for two weeks and gets manually replayed later will still have the old shape. Keep old version handlers around for at least as long as your dead-letter retention window, and only remove them once you've confirmed nothing in that table still references the old version.

Step 5a: Handle the reverse case, a new consumer reading an old queue during a rollback

The upgrade chain above assumes deploys only move forward, but rollbacks happen. If a bad deploy needs to be rolled back after it's already started enqueuing v2 payloads, the previous consumer version, which only knows about v0 and v1, will hit payloads it's never seen. Guard against this by having the enqueue side, not just the consumer side, check what schema version the currently deployed consumer fleet actually supports before writing a new-format payload, or by keeping the upgrade chain one version ahead of what you're actively enqueuing so a rollback still has a valid path forward.

MAX_SUPPORTED_VERSION_ENV = "CONSUMER_MAX_SCHEMA_VERSION"

def safe_enqueue_version():
    # Never enqueue a version newer than what's actually deployed and running
    return min(CURRENT_VERSION, int(os.environ.get(MAX_SUPPORTED_VERSION_ENV, CURRENT_VERSION)))
Enter fullscreen mode Exit fullscreen mode

Step 6: Log the version distribution during a migration

While an upgrade chain is actively in use, add a simple counter or log line recording which version each processed job arrived at. This gives a concrete signal for when it's actually safe to retire an old handler, once the count of jobs arriving at version zero drops to zero for a sustained period, instead of guessing based on how long ago the schema changed.

import logging

def process_job(payload):
    logging.info(f"processing schema_version={payload.get('schema_version', 0)}")
    ...
Enter fullscreen mode Exit fullscreen mode

Step 7: Treat the upgrade chain like production code, with its own tests

It's easy to write upgrade_v0_to_v1 once, confirm it works against one sample payload, and never touch it again. Give the upgrade chain its own test suite that feeds in real historical payload shapes (pull a handful from your dead-letter table or logs if you have them) and asserts the output matches what the current handler expects. Schema changes tend to arrive in a rush right before a deploy, which is exactly when an untested upgrade function is most likely to have a subtle bug nobody catches until a real old-format job hits it in production.

def test_upgrade_chain_handles_real_v0_payload():
    old_payload = {"order_id": "abc123", "action": "send_confirmation", "attempts": 2}
    upgraded = upgrade_v0_to_v1(old_payload)
    assert upgraded["schema_version"] == 1
    assert upgraded["retry_count"] == 2
    assert "attempts" not in upgraded
Enter fullscreen mode Exit fullscreen mode

Where this fits with the rest of the queue

Payload versioning solves a different problem than queue durability, but the two show up in the same systems for the same reason: once a job queue has been running in production for a while, both old-format jobs and crash recovery become things that actually happen rather than edge cases in a design doc. We covered the durability half, keeping a job from disappearing if a worker restarts mid-task, in a longer guide on building a background job queue that survives a server restart.

Most of the tooling here works the same regardless of whether the queue is backed by Redis, a plain PostgreSQL table, or a managed broker like Google Cloud Pub/Sub; what matters is the discipline of never assuming every payload the consumer sees matches the schema you just shipped.

The smallest version of this worth doing on day one

If your job system is brand new and none of this feels urgent yet, the one step worth doing regardless is Step 1: add the schema_version field now, even set to a constant 1 with no branching logic behind it yet. Retrofitting a version field onto payloads that have already shipped without one means every historical job in flight or sitting in a dead-letter table is permanently unversioned, which turns any future schema change into a guessing game about what shape old data might be in. The field costs nothing to add early and becomes expensive to add after the fact.

We've seen the retrofit version of this problem play out on more than one client project: a job system that ran for a year or two with no version field at all, followed by a schema change that had no reliable way to tell an old payload from a new one, forcing the team to fall back on brittle heuristics like checking whether a specific field happened to be present. Every one of those heuristics eventually misclassified at least one real payload. A version field from day one turns that entire class of guesswork into a single integer comparison.

If your team is planning a schema change to a production job queue, 137Foundry can help map out the upgrade path before the deploy goes out, not after the first in-flight job fails on it.

Top comments (0)