I Built a FastAPI QR Code Service and the Library Choice Mattered More Than the Code
A tiny, self-hosted QR code microservice with PNG and SVG output, tunable error correction, color control, and optional logo overlay. Three endpoints. ~80 MB Alpine image. The interesting bit is why it uses
segnoinstead ofqrcode.
QR codes quietly run half the payment, menu, 2FA-setup, and marketing-attribution surface of the internet. And yet every team I've worked with ends up generating them the same way: install qrcode into the main application, write a route handler, forget about it for three years, and then spend an afternoon debugging why Pillow's ABI broke during a Python upgrade.
There's a smaller, cleaner approach: a single-purpose microservice that produces a PNG or SVG from a text payload, deployed once, forgotten forever. That's what qrgen-api is.
📦 GitHub: https://github.com/sen-ltd/qrgen-api
The problem with embedding a QR library in your app
I want to be careful here. There's nothing wrong with calling qrcode.make(...) inside your Django view. For a prototype or a low-traffic admin tool, it's correct. But at any real volume, four things start to bite:
The transitive dependency footprint.
qrcodepulls Pillow by default. Pillow is ~15 MB, requires libjpeg/libpng/zlib at the C level, and has a surprisingly chunky CVE history for an image library. You're now shipping all of that to every instance of your app just because one endpoint wanted to emit a QR code.Cold-start and memory cost. Importing Pillow on every worker, in every replica, to support a feature that runs 0.1% of your traffic is the kind of thing that looks like rounding error until your app is serving 100 workers across 20 pods and you're paying for the Pillow resident memory 2000 times over.
Coupling. The QR logic lives inside your application's Python version, dependency tree, and deploy cycle. A major Pillow upgrade forces you to coordinate it with the rest of the app, even though nothing about QR generation has actually changed.
Testability. Mocking out the QR library in unit tests is annoying because the "thing under test" is the bytes it produced. Running a whole Django test suite just to verify a route that returns an image is overkill.
A ~80 MB microservice with three endpoints fixes all four. It has its own release cadence, its own memory footprint, its own base image, and your app now makes one HTTP call that's trivial to mock.
The segno-vs-qrcode decision
There are basically two real choices in the Python ecosystem for QR encoding:
-
qrcode(sometimes calledpython-qrcode) — the one everyone installs by muscle memory. Defaults to Pillow for output. SVG is available but tacked on. -
segno— pure-Python, ~80 KB of code, first-class SVG output, no C extensions for the encoder.
For an Alpine-based microservice, the segno advantages stack up fast:
-
Pure Python, zero C. On Alpine, any package with a C extension means building from source unless you already have
musllinuxwheels.segnohas no C extensions at all, so it just installs, always. If you're building a small image where every dependency is a negotiation with the C toolchain, that matters. -
First-class SVG.
segnotreats SVG as a primary output, not an afterthought. You getxmldecl, namespace control, color control, and a predictable file that browsers render the same everywhere. -
Smaller image. The
qrcode+ Pillow combination is ~15 MB of dependencies on top of Python.segnoalone is ~80 KB. That's a ~200x difference for the core QR encoder. -
Better maintenance hygiene. As of 2026, both are maintained, but
segno's release cadence and bug-fix turnaround are noticeably better, and the docs are a meaningful step up.
The only catch: segno's PNG output uses a built-in mini-writer, not Pillow. That's great for a pure-SVG deployment, but we still need Pillow for one thing — compositing a logo onto the center of the QR. So Pillow comes back in, but only for one route, not for every QR we emit.
The architecture, such as it is
qrgen-api/
├── src/qrgen_api/
│ ├── main.py # FastAPI app, routes
│ ├── generator.py # segno + Pillow wrapper, pure functions
│ ├── models.py # Pydantic models
│ ├── validators.py # hex color, ec level, format enum, magic-byte
│ └── logging.py # JSON middleware
└── tests/...
The design rule I followed is the same one I apply to every microservice:
The generator module must not import FastAPI.
If generator.py can be unit-tested with data = make_qr(text="hi"), without spinning up an app, then the route layer stays trivially thin and the generator can be reused from a CLI, a worker, or a completely different web framework later. No regrets.
Here's what that looks like in practice.
The segno wrapper
import io
import segno
def make_qr(
*,
text: str,
size: int = 8,
border: int = 4,
ec: str = "M",
fg: tuple[int, int, int] = (0, 0, 0),
bg: tuple[int, int, int] = (255, 255, 255),
fmt: str = "png",
) -> bytes:
qr = segno.make(text, error=ec.lower())
buf = io.BytesIO()
dark = "#{:02x}{:02x}{:02x}".format(*fg)
light = "#{:02x}{:02x}{:02x}".format(*bg)
if fmt == "png":
qr.save(buf, kind="png", scale=size, border=border,
dark=dark, light=light)
elif fmt == "svg":
qr.save(buf, kind="svg", scale=size, border=border,
dark=dark, light=light, xmldecl=True, svgns=True)
else:
raise ValueError(f"unknown format: {fmt!r}")
return buf.getvalue()
Three things to notice:
-
No
Imageobjects leave this function. The return type isbytes. The route handler doesn't need to know what the intermediate rendering pipeline was. -
fgandbgcome in as RGB tuples. I normalize hex strings to tuples invalidators.pyso the generator never touches user input directly — by the timefgreaches here, it's already been parsed or rejected with a 422. -
The format switch is explicit. No "figure it out from Accept headers" magic. The caller asks for
pngorsvgand gets it. Anything else is aValueError, which the route handler re-shapes into aninvalid_formaterror.
The logo overlay
This is the one place Pillow shows up. I went back and forth on whether to use Pillow at all or try to composite the logo directly into the SVG output, but compositing a raster logo into SVG means either base64-embedding a PNG (ugly, and defeats the point of SVG) or forcing the logo to be pre-vectorized upstream (not reasonable).
from PIL import Image
def make_qr_with_logo(
*,
text: str,
logo_bytes: bytes,
size: int = 10,
border: int = 4,
fg: tuple[int, int, int] = (0, 0, 0),
bg: tuple[int, int, int] = (255, 255, 255),
logo_area_pct: float = 20.0,
) -> bytes:
# Generate the base QR. EC=H is hardcoded here — callers can't lower it.
qr = segno.make(text, error="h")
base_buf = io.BytesIO()
qr.save(base_buf, kind="png", scale=size, border=border,
dark="#{:02x}{:02x}{:02x}".format(*fg),
light="#{:02x}{:02x}{:02x}".format(*bg))
base_buf.seek(0)
base = Image.open(base_buf).convert("RGBA")
logo = Image.open(io.BytesIO(logo_bytes)).convert("RGBA")
# Scale the logo to logo_area_pct of the QR's width, preserving ratio.
target_w = max(1, int(base.width * logo_area_pct / 100.0))
scale = target_w / logo.width
target_h = max(1, int(logo.height * scale))
logo = logo.resize((target_w, target_h), Image.Resampling.LANCZOS)
# White pad under the logo so transparent corners don't look like modules.
pad = 4
pad_box = Image.new("RGBA",
(target_w + pad * 2, target_h + pad * 2),
(255, 255, 255, 255))
cx = (base.width - pad_box.width) // 2
cy = (base.height - pad_box.height) // 2
base.alpha_composite(pad_box, (cx, cy))
base.alpha_composite(logo, (cx + pad, cy + pad))
out = io.BytesIO()
base.convert("RGB").save(out, format="PNG")
return out.getvalue()
The padding rectangle matters more than it looks. Without it, a logo with transparent corners would bleed into the surrounding modules and scanners would try to interpret those transparent regions as QR data. With a flat white pad under the logo, the scanner sees a clean rectangle of "no data here" and the error correction picks it up.
The route handler, on purpose, is boring
@app.get("/qr", response_model=None)
async def get_qr(
text: str = Query(..., min_length=1),
size: int = Query(8, ge=2, le=40),
border: int = Query(4, ge=0, le=20),
ec: str = Query("M"),
fg: str = Query("#000000"),
bg: str = Query("#ffffff"),
format: str = Query("png"),
) -> Response | JSONResponse:
return _generate(text, size, border, ec, fg, bg, format)
_generate does the validation chain (text length → ec level → format → hex colors) and returns either a Response with the image bytes or a JSONResponse with the error shape {"error": "...", "detail": "..."}. The response_model=None is there because FastAPI's response-model inference doesn't handle the Response | JSONResponse union — telling it not to introspect the return type is the intended escape hatch.
POST /qr reuses the same _generate function with a Pydantic body. I wrote a test asserting that GET /qr?... and POST /qr with the same params produce byte-identical output. That's the kind of test that catches you six months later when someone "helpfully" adds a different default to one of the routes.
Error correction, demystified
Every QR library exposes an error correction parameter, and almost every front-end tool hides it. I think that's a mistake. The L/M/Q/H levels are one of the few user-facing knobs where the tradeoff is actually visible:
| Level | Recovers up to | Use when |
|---|---|---|
L |
~7% damage | Clean digital display, max payload density |
M |
~15% damage | General print (the default everywhere) |
Q |
~25% damage | Outdoor posters, uneven print runs |
H |
~30% damage | Logo overlays, wear-and-tear, tiny sizes |
Higher EC means more modules for the same payload, which means the QR gets denser for the same physical size. At some point, that density makes a small QR harder to scan — not easier, despite the "more redundancy" intuition. This is particularly bad on phone screens at small sizes: ec=H QRs for long URLs can actually be worse on a small phone than ec=M of the same data.
Exposing this as an explicit parameter means your application can make an informed decision per use case, instead of hoping that whatever the default was is right for everyone. The logo-overlay endpoint is where this really bites: I hardcoded ec=H there and made lower levels a hard 422, because anything below H actively doesn't work with a logo that covers ~4% of the QR.
Magic bytes for the logo upload
The last defensive piece: content-type validation on uploads. A client-supplied Content-Type: image/png header is meaningless — clients lie, proxies rewrite, and nobody enforces it.
def sniff_logo(data: bytes) -> str:
if len(data) < 12:
raise UnsupportedLogoError("logo too short to be an image")
if data[:8] == b"\x89PNG\r\n\x1a\n":
return "png"
if data[:3] == b"\xff\xd8\xff":
return "jpeg"
raise UnsupportedLogoError(
"logo must be PNG or JPEG (magic bytes do not match)"
)
Eight bytes of PNG magic, three bytes of JPEG SOI marker, nothing else. A test file uploaded as logo.png with Content-Type: image/png and the bytes "totally not an image" gets a clean 415 at this layer, before Pillow is ever asked to decode it. Pillow's own error handling is fine, but you want the type check to fail before you've spent the CPU cycles unpacking the payload, and you want the error to say "wrong type" instead of "Pillow says so".
Tradeoffs I left on the table
-
MAX_TEXT_LEN=2000. Bigger than that and the QR gets unreadable at any reasonable display size. If you're encoding a 4KB JSON blob into a QR, use a URL instead. -
No contrast validation. A caller can request
fg=#ffffffonbg=#ffffffand get a blank image. I considered rejecting it but decided against — the contract is "render what I asked for", not "guess what I meant". Document the footgun and move on. -
No format auto-detection from Accept headers. The
formatparam is explicit. Content negotiation on a two-format API is more complexity than the feature deserves. -
No caching. The service is stateless, and a QR for a given payload is deterministic, so this is a great candidate for a front-of-service CDN cache on the
/qrquery string. But that belongs in the reverse proxy, not in the app. -
/qr/with-logoisn't available with SVG output. Rasterizing a logo into an SVG QR means either embedding it as base64 (ugly) or requiring vector input (unreasonable). If you want an SVG with a logo, composite it yourself upstream.
Try it in 30 seconds
git clone https://github.com/sen-ltd/qrgen-api
cd qrgen-api
docker build -t qrgen-api .
docker run --rm -p 8000:8000 qrgen-api
# In another shell:
curl -o qr.png "http://localhost:8000/qr?text=https://sen.ltd&size=10&ec=H"
curl "http://localhost:8000/qr?text=hello&format=svg" > qr.svg
# Logo overlay:
curl -F text=https://sen.ltd -F logo=@your-logo.png \
-o branded.png http://localhost:8000/qr/with-logo
Image size is ~80 MB. Startup is under a second. The whole test suite (46 tests) runs in under a second too — the unit tests barely count because most of them are just "pass bytes, get bytes, assert on bytes".
What I'd do differently
The one thing I'd push further is splitting into two images: a pure-SVG build that drops Pillow entirely, and a full build that keeps the logo endpoint. That'd get the SVG-only image down into the 50 MB range and make a clear statement about which features cost what. For this version I kept things simple and shipped one image, but if you're deploying hundreds of replicas, the savings would add up.
The code is MIT-licensed, the Dockerfile is non-root and multi-stage, and the generator module is 130 lines you could lift wholesale into any Python project that wants a clean QR-to-bytes function without the full HTTP layer. If your application has a qrcode.make(...) call today, consider whether it belongs in your main process at all — or whether it's the kind of thing you'd rather never think about again.

Top comments (0)