QR Code Generation: Deterministic Payload Encoding for Badge Workflows
QR code generation is the strict transformation boundary that converts a normalized attendee record into a scannable, error-corrected credential. It is one stage of the Badge Generation & Template Sync pipeline, sitting immediately after dynamic field mapping has resolved and versioned each record, and immediately before layout composition embeds the encoded asset into a print-ready template. For event operations teams the operational problem is narrow but unforgiving: every badge must carry a credential that scans on the first attempt at an access gate, encodes only the bytes it needs, and can be regenerated identically on retry without producing a second, conflicting code. The failure mode this stage exists to prevent is the badge that prints cleanly but scans to nothing — a dead credential discovered only when an attendee is already standing at the door.
To hit that bar the stage must stay stateless, idempotent, and strictly bounded. It consumes a resolved record, applies deterministic payload encoding under a chosen error-correction tier, and emits a rasterized or vectorized asset plus audit metadata. It performs no layout, no signing, and no delivery. By isolating encoding from composition and routing, teams scale badge production to tens of thousands of records without leaking latency into downstream stages or introducing the template drift that follows when encoding logic quietly starts making layout decisions.
Scope Boundary: What QR Generation Owns Link to this section
To remain horizontally scalable and safe to retry, the encoding stage enforces a hard boundary. It owns payload construction, capacity validation, and rasterization — nothing else. Anything that involves visual placement, cryptographic authority, or physical delivery is delegated to an adjacent stage, so that a printer stall or a template regression can never propagate backward into encoding state.
| In-Scope (owned here) | Out-of-Scope (delegated) |
|---|---|
| Deterministic payload string construction | Canonical field names and coercion → dynamic field mapping |
| Capacity validation against the error-correction tier | Visual placement, quiet-zone sizing on the badge → badge layout architecture |
| Rasterization / vectorization and checksum emission | PDF assembly, thermal dispatch, wallet delivery → PDF routing workflows |
| Error-correction fallback and overflow routing | 1D barcode density and scan-distance tuning → barcode threshold tuning |
| Structured error capture and DLQ routing | Token minting, HMAC signing authority → upstream security boundary |
The check_in_token this stage encodes is never invented here — it arrives already resolved from the mapping layer, which governs it against the attendee field mapping rules and the event taxonomy schema. Encoding treats the token as opaque. Keeping that distinction sharp is what lets the stage remain a pure function: the same resolved record always yields the same asset, the same checksum, and the same base64 string.
The Payload Contract Link to this section
The encoder expects a rigid input schema; anything ambiguous is rejected before a single module is placed. Rather than hand-rolled if branches, the contract is a Pydantic v2 model that declares required fields, enumerated tiers, and deterministic coercion. This makes validation declarative and gives every downstream consumer a record they can trust without re-checking.
| Field | Type | Constraint |
|---|---|---|
attendee_id |
UUIDv4 |
Required, non-null |
event_slug |
str |
Required, [a-z0-9-], max 32 chars |
check_in_token |
str |
HMAC-SHA256 hex digest or short sequential id |
access_tier |
enum |
general, vip, staff, press |
render_format |
enum |
png, svg (default png) |
error_correction |
enum |
L, M, Q, H (default M) |
import uuid
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
class QRPayloadContract(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="ignore")
attendee_id: uuid.UUID
event_slug: str = Field(min_length=1, max_length=32, pattern=r"^[a-z0-9-]+$")
check_in_token: str = Field(min_length=1, max_length=128)
access_tier: Literal["general", "vip", "staff", "press"]
render_format: Literal["png", "svg"] = "png"
error_correction: Literal["L", "M", "Q", "H"] = "M"
@field_validator("check_in_token", mode="before")
@classmethod
def strip_token(cls, v: str) -> str:
# Guard against upstream bloat: a full JWT is not a check-in token.
if v and v.count(".") == 2:
raise ValueError("check_in_token looks like a JWT; encode a short opaque id")
return v
Each rule earns its place. The pattern on event_slug keeps slugs URL-safe so the encoded payload never carries characters that force a mode switch into byte segments. The max_length cap on check_in_token is the first line of defense against the single most common production incident — an upstream stage accidentally concatenating a full JWT or an unhashed blob into the token field, blowing past QR capacity. Validation runs at service ingress; malformed payloads never reach the encoder and are instead quarantined via the dead-letter queue routing mechanism with a structured envelope, exactly as upstream schema validation pipelines do, so triage tooling stays uniform across the whole ingestion-to-print chain.
The emitted payload itself is a fixed, delimiter-joined string so that identical inputs always produce identical bytes:
{event_slug}|{attendee_id}|{check_in_token}|{access_tier}
Payload capacity is governed by the ISO/IEC 18004 standard and scales inversely with the error-correction tier. Binary payloads at tier L can technically reach 2,953 bytes, but badge workflows should target well under 128 bytes: shorter payloads mean fewer modules, larger module size at a fixed print area, and faster first-scan acquisition on both phone cameras and fixed gate scanners.
Deterministic Implementation Link to this section
The encoder prioritizes throughput, memory safety, and deterministic output. It builds the payload, enforces a capacity guardrail with an explicit error-correction fallback, rasterizes in-memory, and attaches a checksum and correlation ID for end-to-end tracing. Oversized payloads are logged and routed out rather than allowed to crash the batch worker.
Note: segno raises segno.DataOverflowError (a subclass of ValueError) when a payload exceeds the capacity of the selected error-correction level. Catching ValueError covers it without importing a private module.
import io
import base64
import hashlib
import logging
import uuid
from dataclasses import dataclass
import segno
logger = logging.getLogger("qr_generation")
@dataclass
class QRGenerationResult:
payload_b64: str
mime_type: str
checksum: str
error_correction: str
correlation_id: str
fallback_applied: bool = False
def _build_payload(rec: "QRPayloadContract") -> str:
return f"{rec.event_slug}|{rec.attendee_id}|{rec.check_in_token}|{rec.access_tier}"
def _checksum(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()[:16]
def generate_badge_qr(rec: "QRPayloadContract") -> QRGenerationResult:
correlation_id = str(uuid.uuid4())
payload = _build_payload(rec)
ec = rec.error_correction
fallback = False
# Capacity guardrail: past ~400 bytes, M/H density spikes and first-scan
# acquisition degrades on gate hardware — downgrade EC deterministically.
if len(payload.encode("utf-8")) > 400 and ec in ("Q", "H", "M"):
logger.warning(
"qr.capacity_downgrade",
extra={
"correlation_id": correlation_id,
"attendee_id": str(rec.attendee_id),
"length": len(payload),
"from_ec": ec,
"to_ec": "L",
},
)
ec, fallback = "L", True
try:
qr = segno.make_qr(payload, error=ec, micro=False)
buf = io.BytesIO()
if rec.render_format == "svg":
qr.save(buf, kind="svg", scale=10, border=2)
mime = "image/svg+xml"
else:
qr.save(buf, kind="png", scale=10, border=2)
mime = "image/png"
raw = buf.getvalue()
except ValueError as exc:
# segno.DataOverflowError subclasses ValueError: payload too large even at L.
logger.error(
"qr.data_overflow",
extra={
"correlation_id": correlation_id,
"attendee_id": str(rec.attendee_id),
"length": len(payload),
"error": str(exc),
},
)
raise RuntimeError(
f"QR encoding failed for {rec.attendee_id}: payload exceeds capacity"
) from exc
return QRGenerationResult(
payload_b64=base64.b64encode(raw).decode("ascii"),
mime_type=mime,
checksum=_checksum(raw),
error_correction=ec,
correlation_id=correlation_id,
fallback_applied=fallback,
)
Four properties make this safe under load. Memory safety: io.BytesIO() keeps rasterization off disk, avoiding file-descriptor leaks in containerized workers. Deterministic output: identical inputs yield identical base64 and identical checksums, which enables CDN caching, deduplication, and non-destructive retries. Explicit fallback: an automatic tier downgrade sets fallback_applied, so PDF routing workflows can bump print resolution or scanner sensitivity for that record. Correlation propagation: the correlation_id minted here follows the asset downstream so a single id reconstructs an end-to-end trace from encode to print.
Production Debugging & Observability Link to this section
Fast incident resolution rests on observable failure boundaries. The encoder emits JSON events with explicit extra fields so a log platform can facet on them directly — a Datadog facet on @correlation_id or an ELK filter on error isolates a fault in seconds rather than grepping raw lines. A representative overflow event looks like this:
{
"event": "qr.data_overflow",
"correlation_id": "a3f9c2e1-7b64-4d0a-9c11-2e5f8d6b1a04",
"attendee_id": "6f1c9b2e-2d44-4a10-9c31-2e5f8d6b1a04",
"length": 812,
"error": "Data too large to fit into a QR Code with error level L",
"level": "error"
}
That single line tells the on-call engineer that the payload for one attendee ballooned to 812 bytes — far past a healthy sub-128-byte credential — which points straight at a bloated check_in_token upstream rather than an encoder defect. The overflow branch serializes the failed record alongside this envelope and pushes it to a dedicated dead-letter queue for manual review or automated retry, sharing the same queue shape used across the pipeline so triage tooling is uniform. Two observability practices matter most in production:
- Checksum on both ends. The
checksumis carried with the asset. When the print dispatcher recomputes it and finds a mismatch, that signals base64 truncation or pipeline corruption — verify the consumer decodes withbase64.b64decode()and does not strip padding. - Never block the batch on one record. A single overflowing attendee routes to review with a placeholder asset; the rest of the batch proceeds. Blocking the whole run on one malformed record is the difference between a one-attendee reprint and a stalled registration line.
Performance & Memory Constraints Link to this section
Encoding is CPU-light per record but is invoked once per attendee, so at conference scale its per-record overhead and its interaction with the worker pool dominate. The table captures the constraints that actually bite during an opening-morning spike and how each is contained.
| Component | Constraint | Mitigation |
|---|---|---|
| Rasterization buffer | Large scale values inflate PNG bytes and memory per record |
Fix scale=10, border=2; emit SVG for print masters to keep bytes flat |
| Worker concurrency (CPU-bound) | The GIL serializes rasterization, so threads give no speedup | Scale with processes (Celery/Gunicorn workers = CPU cores), not threads |
| DLQ producer connection | A per-overflow broker connect exhausts the pool during a bloat storm | Pool connections (max_connections ~ 2× worker count); publish via a shared client |
| Batch queue depth | Unbounded prefetch lets a poison-pill backlog balloon worker memory | Cap prefetch_count at 32–64 and offload retries to async batch processing |
| Redundant re-encoding | Retrying identical records burns CPU re-rasterizing the same asset | Key a cache on checksum; deterministic output makes cache hits safe |
The recurring theme is that encoding should stay a pure, allocation-light function and push every stateful or blocking concern — connections, retries, queue depth — outward to the batch layer designed to hold it.
Incident Triage Checklist Link to this section
When QR-failure alerts fire, the target is under 15 minutes to containment. Work the list in order; each step is non-destructive until the final rollback.
- Confirm the blast radius. Query your log platform for
qr.data_overflowgrouped byevent_slugover the last 15 minutes. A single event slug means one campaign’s upstream token changed shape; a spread means a shared mapping regression. - Inspect the DLQ depth.
redis-cli LLEN dlq:qr_generation— a fast-climbing depth confirms an active bloat rather than a stale backlog. Peek without draining:redis-cli LRANGE dlq:qr_generation 0 4. - Pull one failing record by correlation id:
redis-cli HGETALL dlq:qr_generation:<correlation_id>. Measurelen(check_in_token)— if it carries a full JWT or unhashed blob, the fault is upstream in mapping, not here. - Check the error-correction tier. If scans fail at gate distance rather than overflowing, the tier is too high for the print area — the guardrail downgrades to
L, but confirmfallback_appliedis reaching the print stage so resolution compensates. - Patch the token source, not the record. Fix the upstream field that bloated the token; never hand-edit records in the queue, so the fix replays reproducibly for every quarantined payload.
- Deploy and drain-replay. Ship the patch, then requeue the DLQ through
generate_badge_qr. Watch overflow events fall andLLEN dlq:qr_generationdrain to zero. Rollback path: because output is deterministic and idempotent, reverting to the previous encoder build and reprocessing is always safe — a replay can only reproduce identical assets, never conflicting ones.
For protocol-level validation, align capacity limits with scanner hardware against the official ISO/IEC 18004:2015 specification.
Frequently Asked Questions Link to this section
Should a record with an oversized check_in_token be rejected or truncated?
Rejected, never truncated. Truncating a token silently produces a credential that scans to a non-existent identity. The contract caps token length and routes overflow to the dead-letter queue so the upstream bloat is fixed at its source in dynamic field mapping.
Which error-correction level should badge QR codes use by default?
M is the right default: it tolerates ~15% damage — enough for a laminated badge that gets scuffed — without the density penalty of Q or H. Reserve higher tiers for badges printed on textured or reflective stock, and let the capacity guardrail downgrade to L only when a payload genuinely demands it.
Why emit SVG instead of PNG for print masters? Vector output stays crisp at any print DPI and keeps byte size flat regardless of physical badge dimensions, which matters when PDF routing workflows scale the same asset across lanyard, wristband, and A6 formats. PNG remains fine for on-screen check-in and wallet passes.
Related Link to this section
- Badge Generation & Template Sync — the parent pipeline this encoding stage feeds; its overview shows every stage from ingestion to print.
- Dynamic Field Mapping — the upstream stage that resolves and versions the
check_in_tokenthis encoder treats as opaque input. - PDF Routing Workflows — the downstream consumer that embeds the base64 asset into templates and dispatches to print, thermal, or wallet targets.
- Barcode Threshold Tuning — the sibling stage covering 1D barcode density and scan-distance calibration for the same badges.
- Async Batch Processing — owns the dead-letter queue and retry machinery this stage routes capacity overflows into.