Choosing a QR Library for Event-Scale Badge Generation
Symptom Statement Link to this section
You are printing thirty thousand badges the night before doors open, and the QR step is the bottleneck that will not move. Either the generation loop is crawling — a Pillow image allocated, encoded, and flushed to disk for every attendee — and the batch will not finish before the print vendor’s cutoff, or the codes come out of the loop fast but land on the badge as a soft, aliased raster that a handheld scanner refuses at arm’s length under venue lighting. The choice of QR library made months earlier is what decides which of those two failures you are living with tonight. This decision sits inside the QR code generation stage, which owns how the code is encoded, sized, and handed to the renderer for the wider Badge Generation & Template Sync pipeline. The wrong library does not fail loudly during development, where you generate ten codes and they all scan off a laptop screen; it fails at event scale, where throughput and print sharpness are the only two numbers that matter and they pull against each other. This page compares the four libraries an event pipeline actually reaches for — segno, qrcode plus Pillow, pyqrcode, and ReportLab’s built-in QR widget — and gives a selector that binds each output target to the backend that will not tank it.
Root Cause Analysis Link to this section
A library that works fine in a unit test can still be the wrong tool for a thirty-thousand-badge run. Three concrete properties decide whether the choice holds up at scale, and a badge pipeline gets bitten by whichever one it ignored.
- Raster output rendered for print.
qrcode’s default backend and any PNG path produce a fixed grid of pixels. Scaled up to a badge’s physical module size, that grid either shows visible stair-stepping or must be oversampled to a huge bitmap to stay sharp. A vector code — SVG, EPS, or paths drawn straight into a PDF — is resolution-independent and prints crisp at any DPI the plate supports. - Per-code allocation churn that collapses throughput. The raster path allocates a Pillow
Image, rasterizes every module, PNG-encodes it, and flushes to disk once per attendee. At tens of thousands of codes that object churn and I/O dominate wall-clock time. Pure-vector generation skips the bitmap entirely and runs roughly an order of magnitude faster on the same box. - Error-correction and quiet-zone defaults left implicit. The libraries do not share defaults, and a code generated at error level
Lwith a two-module border may scan off a screen yet fail on a laminated badge. Picking the backend without pinning error correction and border defers a physical-print failure to the venue floor — the class of problem covered by fixing QR codes that fail to scan on glossy badge stock and by tuning barcode quiet zones for handheld scanners. - Dependency weight and maintenance risk.
qrcodepulls Pillow, a large native wheel; an SVG factory on top pullslxml.pyqrcodeis pure Python but unmaintained since 2016.segnoand ReportLab’s widget carry no extra native dependency for the paths a badge pipeline needs, which keeps the container small and the supply chain auditable.
Library Comparison Link to this section
The four candidates against the dimensions that decide an event-scale run. “Vector” means output that stays sharp at any print resolution; “raster” means a fixed pixel grid.
| Library | Native output | Print sharpness | Relative throughput | Memory per code | EC control | Dependency weight |
|---|---|---|---|---|---|---|
segno |
SVG · EPS · PDF · PNG | Vector — crisp at any DPI | Highest (pure vector, no bitmap) | Lowest — matrix only | Full L/M/Q/H + Micro QR |
Zero deps (pure Python) |
ReportLab QrCodeWidget |
Vector drawn into the PDF | Vector — crisp at any DPI | High (no intermediate file) | Low — drawn inline | barLevel L/M/Q/H |
Already present if you use ReportLab |
qrcode + Pillow |
PNG raster (SVG needs lxml) |
Raster — blurs unless oversampled | Low (image alloc + encode + I/O) | High — full bitmap held | Full L/M/Q/H |
Pillow (heavy native wheel) |
pyqrcode |
SVG · EPS · PNG (needs pypng) |
Vector, but unmaintained | Moderate | Low | Full L/M/Q/H |
Small, but abandoned |
The table collapses to one rule: if the code ends up on paper, it should leave the generation loop as vector. segno is the default for standalone print assets, ReportLab’s widget is the default when you are already drawing the badge with ReportLab, qrcode earns its place only for screen previews, and pyqrcode is a legacy footnote you should not adopt new.
Symptom-to-Resolution Matrix Link to this section
Throughput collapses on the QR step at batch scale Link to this section
Symptoms
- The generation loop that finished ten codes instantly takes many minutes for the full attendee list.
- CPU sits busy on image encoding and the disk is hot with thousands of small PNG writes.
Root cause. A raster backend allocates and encodes a full bitmap per code, so per-object overhead and I/O dominate once the count reaches tens of thousands.
Fix
- Switch standalone-asset generation to
segnoand emit vector:segno.make(data, error="q").save(buf, kind="svg", scale=6, border=4). - Keep the output in memory (
io.StringIO) and stream it to the renderer instead of round-tripping through disk. - If the code is drawn into a ReportLab badge, drop the intermediate asset entirely and use
QrCodeWidget, which writes vector paths straight onto the canvas built in designing scalable badge templates with Python ReportLab.
QR prints soft or aliased and scanners hesitate Link to this section
Symptoms
- Codes scan off a monitor but fail or are slow off the printed badge.
- Under magnification the module edges are stair-stepped, not crisp.
Root cause. A raster PNG sized for screen was scaled to physical badge dimensions, so each module became a blurred block of pixels below the scanner’s resolving power.
Fix
- Generate vector for anything printed —
segnoSVG/EPS or ReportLab’s widget — so module edges stay hard at the plate’s DPI. - If a raster backend is unavoidable, oversample: set
box_sizeso each module is at least 10 device pixels at final print DPI, and accept the memory cost. - Pin error correction to
QorHand enforce a four-module quiet zone so the physical margin survives lamination; the glare-specific version of this failure lives in fixing QR codes that fail to scan on glossy badge stock.
The QR dependency bloats or breaks the build Link to this section
Symptoms
- The badge container image is large and slow to build because of Pillow and
lxmlnative wheels. - A pinned
pyqrcoderelease throws deprecation warnings and has no upstream fixes.
Root cause. A heavy or abandoned dependency was chosen for an output format a lighter library covers natively.
Fix
- Prefer
segnofor standalone codes — it is pure Python with no compiled dependency, so the wheel is tiny and reproducible. - Reuse ReportLab’s
QrCodeWidgetwhen the pipeline already imports ReportLab; it adds no new dependency at all, and the same fonts you configure under font & glyph embedding travel with that document. - Retire
pyqrcodefrom new pipelines; keep it only behind a frozen version if a legacy asset format requires it.
Minimal Working Implementation Link to this section
The block below benchmarks a vector batch (segno) against a raster batch (qrcode + Pillow) on the same payloads, then binds a validated QRRenderProfile to the backend that suits its output target. The verification block asserts the selector never routes a printed target to a raster backend and never picks an abandoned library.
import io
import time
from typing import Literal
import segno
import qrcode
from pydantic import BaseModel, ConfigDict, Field, field_validator
def gen_vector_batch(payloads: list[str], scale: int = 6, border: int = 4,
ec: str = "q") -> list[str]:
"""segno → SVG strings held in memory; no bitmap, no disk round-trip."""
out: list[str] = []
for data in payloads:
code = segno.make(data, error=ec)
buf = io.StringIO()
code.save(buf, kind="svg", scale=scale, border=border,
dark="#000000", light="#ffffff")
out.append(buf.getvalue())
return out
def gen_raster_batch(payloads: list[str], box_size: int = 6,
border: int = 4) -> list[bytes]:
"""qrcode + Pillow → PNG bytes; allocates and encodes a bitmap per code."""
out: list[bytes] = []
for data in payloads:
code = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_Q,
box_size=box_size,
border=border,
)
code.add_data(data)
code.make(fit=True)
img = code.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format="PNG")
out.append(buf.getvalue())
return out
class QRRenderProfile(BaseModel):
"""Where the QR ends up, and how many — the two inputs that pick a backend."""
model_config = ConfigDict(strict=True, extra="forbid")
output_target: Literal["reportlab_pdf", "standalone_print", "screen_preview"]
volume: int = Field(..., ge=1)
error_correction: Literal["l", "m", "q", "h"] = "q"
@field_validator("error_correction", mode="before")
@classmethod
def _lower(cls, v: str) -> str:
return v.lower() if isinstance(v, str) else v
PRINTED_TARGETS = {"reportlab_pdf", "standalone_print"}
def select_backend(profile: QRRenderProfile) -> str:
"""Bind an output target to the backend that will not tank it."""
if profile.output_target == "reportlab_pdf":
return "reportlab-native-qr" # vector drawn straight into the canvas
if profile.output_target == "standalone_print":
return "segno-svg" # pure-Python vector, high throughput
return "qrcode-png" # raster is fine for screen only
if __name__ == "__main__":
payloads = [f"https://badge.example/a/{i:06d}" for i in range(200)]
t0 = time.perf_counter()
svgs = gen_vector_batch(payloads)
t_vec = time.perf_counter() - t0
t0 = time.perf_counter()
pngs = gen_raster_batch(payloads)
t_ras = time.perf_counter() - t0
print(f"segno vector : {t_vec:.3f}s for {len(svgs)} codes")
print(f"qrcode raster: {t_ras:.3f}s for {len(pngs)} codes")
# Selector invariants: printed targets never route to a raster backend.
for target in ("reportlab_pdf", "standalone_print", "screen_preview"):
backend = select_backend(QRRenderProfile(output_target=target, volume=30_000))
if target in PRINTED_TARGETS:
assert backend in {"reportlab-native-qr", "segno-svg"}, backend
else:
assert backend == "qrcode-png", backend
assert select_backend(
QRRenderProfile(output_target="standalone_print", volume=1)
) == "segno-svg"
print("OK: no printed target routed to raster")
The benchmark makes the throughput gap visible and the selector makes the print-sharpness rule mechanical: a reportlab_pdf or standalone_print profile can only ever return a vector backend, so a well-meaning change that points printed badges at qrcode-png fails the assert in CI rather than on the print floor.
Memory & Performance Constraints Link to this section
At event scale the QR step is CPU- and allocation-bound, not I/O-bound once you stop writing per-code files. These are the limits that bite during the overnight run.
| Component | Constraint | Mitigation |
|---|---|---|
Raster bitmap (qrcode/Pillow) |
Full Image held per code; large at high box_size for print DPI |
Prefer vector; if raster is required, generate, stream, and free — never accumulate a list of images |
Vector string buffer (segno) |
SVG strings are small but a full-batch list still grows linearly | Stream each SVG to the renderer or a tar/zip sink; do not hold the whole batch in one list |
| Per-code object churn | Allocating a fresh encoder object per attendee dominates a 30k loop | Reuse the payload loop, keep the encoder call tight, avoid per-code disk I/O |
| Error-correction level | H adds ~30% more modules, raising size and shrinking module pitch at fixed badge width |
Use Q as the default for glossy stock; reserve H for small or heavily laminated codes |
| CPU parallelism (GIL) | Pure-Python encoding holds the GIL, so threads add contention not speed | Shard the attendee list across processes (one interpreter per core), not threads |
Incident Triage & Rollback Link to this section
When the overnight batch is behind schedule or the proof codes scan poorly, work the fast path before rewriting the generator.
- Confirm which failure you have. Time a 500-code slice:
python -c "import mod; mod.bench(500)". Slow wall-clock with hot disk is the throughput failure; fast loop but soft proof scans is the raster-print failure. - Check the backend actually in use.
grep -R "make_image\|QrCodeWidget\|segno.make" badge/— a printed target callingmake_imageis the raster smell; confirm the profile’sselect_backendverdict matches the code path. - Inspect a single emitted asset. Open one output: an SVG is text (
head -c 200 out.svgshows<svg), a PNG is binary. A printed badge sourcing a PNG is the root cause. - Switch printed targets to vector and re-run the slice. Flip the standalone path to
segnoSVG or the ReportLab widget, regenerate the 500-code slice, and confirm both the timing and a magnified proof.
Rollback. Gate the backend behind an env flag: QR_BACKEND=segno for the new vector path, QR_BACKEND=qrcode to revert. If a vector change regresses a downstream imposition step, set QR_BACKEND=qrcode and redeploy — because QRRenderProfile is validated and the selector is pure, reverting only swaps the backend, never the payload or module layout.
Post-rollback validation. Regenerate one badge and assert the emitted QR is vector before releasing the batch:
python -c "import mod; a = mod.gen_vector_batch(['https://badge.example/a/1']); assert a[0].lstrip().startswith('<svg'); print('vector OK')"
Frequently Asked Questions Link to this section
Is segno really faster than qrcode, or is that just the raster encoding?
Most of the gap is the raster path. segno builds the module matrix and serializes it to vector text, while qrcode + Pillow additionally allocates a bitmap, rasterizes every module, and PNG-encodes it per code. At tens of thousands of badges that per-code bitmap work and the disk I/O dominate, so switching to vector is typically an order-of-magnitude win on wall-clock time even before you stop writing files.
If I already render badges with ReportLab, do I need segno at all?
Usually not. ReportLab’s QrCodeWidget draws the code as vector directly onto the badge canvas with no intermediate file and no extra dependency, which is both the fastest and the sharpest option when ReportLab is already in the stack. Reach for segno when you need a standalone SVG or EPS asset outside the PDF — for a kiosk screen, a vendor hand-off, or a separate imposition tool.
Does vector output remove the need to tune error correction and quiet zone?
No. Vector fixes sharpness, not margin or redundancy. A crisp code at error level L with a thin border can still fail on glossy, laminated stock under glare. Keep error correction at Q or H and enforce a four-module quiet zone regardless of backend; those physical parameters are what survive lamination and venue lighting.
Related Link to this section
- QR Code Generation — the parent stage that owns how a code is encoded, sized, and handed to the renderer, of which library choice is the first decision.
- Fixing QR Codes That Fail to Scan on Glossy Badge Stock — the sibling guide for the physical-print scan failure this page’s error-correction and quiet-zone advice guards against.
- Designing Scalable Badge Templates with Python ReportLab — the layout stage where ReportLab’s native QR widget draws vector straight into the badge canvas.
- Tuning Barcode Quiet Zones for Handheld Scanners — the quiet-zone math that any backend you choose must satisfy for reliable handheld scanning.
- Font & Glyph Embedding — the neighboring stage that keeps the text on the same badge crisp and portable, mirroring the vector-first rule this page applies to the code.