If you've ever tried to brand a QR code, you know how it goes: you add your colours, round the corners, drop a logo in the middle, and then a customer's phone just sits there. This post is about how to make a custom QR code that still scans. We'll cover why styled codes fail, what the spec gives you to work with, and some working Python that won't save a code unless it decodes back to the exact URL.
I've been building QR tools for a while, and nearly every "my QR doesn't work" problem comes down to three things: error correction, the quiet zone and contrast.
Why styled QR codes break
A QR code isn't a picture. It's a grid of modules (the little squares) that a scanner samples. To read one, a scanner has to:
- Find it. The three big corner squares (finder patterns) tell the scanner where the code is and which way up it is.
- Separate it from the background. That needs a clear margin all the way round.
- Tell dark from light. Every module gets sorted into "ink" or "paper".
- Repair what's missing. Reed–Solomon error correction rebuilds damaged or covered modules.
Styling interferes with every one of those steps.
1. Error correction: your damage budget
QR codes come with four error-correction levels. Roughly, they can recover this much of the data:
| Level | Recoverable (approx.) | Use it when |
|---|---|---|
| L | 7% | Clean, plain codes where small size matters |
| M | 15% | The usual default |
| Q | 25% | Light styling |
| H | 30% | Logos, artwork, anything decorative |
A logo in the middle isn't a design element as far as the scanner is concerned. It's damage, and it comes out of the same budget as scuffs, glare and a bit of bad print. Pick H and you get the biggest budget, but the code needs more modules to hold the same URL, so it gets denser. A short URL helps a lot here.
2. The quiet zone: blank space is part of the code
The spec asks for a blank margin four modules wide on every side. Designers crop it because it looks like wasted space. Then the code sits against a photo or pattern, and the scanner can't work out where the code stops. Python's qrcode library defaults to border=4 for exactly this reason. Leave it alone.
3. Contrast: dark on light, with a big gap
Scanners turn the image into black and white. Low contrast and gradients push modules into the wrong bucket. Inverted codes (light on dark) are part of the standard, but plenty of readers still handle them badly. Keep a dark foreground on a light background. I use the WCAG contrast ratio as a quick, measurable check and treat 4.5:1 as the minimum.
How to make a custom QR code that still scans: working Python
This script uses the qrcode library with level H, keeps the 4-module quiet zone, refuses low-contrast or inverted colours, adds a modest centre logo, and then scans its own output with OpenCV. It only saves the file if the decoded text exactly matches the URL.
pip install "qrcode[pil]" opencv-python-headless
"""Make a coloured QR code with a centre logo that still scans."""
import cv2
import numpy as np
import qrcode
from PIL import Image, ImageDraw
from qrcode.image.styledpil import StyledPilImage
from qrcode.image.styles.colormasks import SolidFillColorMask
from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer
URL = "https://example.com/menu"
FG = (32, 41, 84) # dark navy: the "ink"
BG = (255, 248, 235) # warm cream: the "paper"
def luminance(rgb):
"""WCAG relative luminance of an sRGB colour."""
def channel(c):
c = c / 255
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (channel(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(a, b):
hi, lo = sorted((luminance(a), luminance(b)), reverse=True)
return (hi + 0.05) / (lo + 0.05)
def make_logo(size):
"""Stand-in logo: a simple circle badge. Swap in your own PNG."""
logo = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(logo)
d.ellipse((0, 0, size - 1, size - 1), fill=BG + (255,))
pad = size // 6
d.ellipse((pad, pad, size - pad, size - pad), fill=(230, 90, 70, 255))
return logo
def build(url, logo_fraction=0.22):
ratio = contrast_ratio(FG, BG)
# Dark-on-light with a big gap. 4.5:1 is a sensible floor; more is better.
if ratio < 4.5 or luminance(FG) > luminance(BG):
raise ValueError(f"Contrast {ratio:.1f}:1 is too low, or colours are inverted")
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_H, # ~30% recoverable
box_size=20, # pixels per module
border=4, # quiet zone: the spec asks for 4 modules
)
qr.add_data(url)
qr.make(fit=True)
img = qr.make_image(
image_factory=StyledPilImage,
module_drawer=RoundedModuleDrawer(),
color_mask=SolidFillColorMask(back_color=BG, front_color=FG),
).convert("RGB")
# Keep the logo well under the 30% budget: 22% of the width is ~5% of the area.
side = int(img.size[0] * logo_fraction)
logo = make_logo(side)
pos = ((img.size[0] - side) // 2, (img.size[1] - side) // 2)
img.paste(logo, pos, logo)
return img, qr.version, ratio
def decodes_to(img, expected):
"""Scan the finished image with OpenCV and compare the exact text."""
arr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
text, _, _ = cv2.QRCodeDetector().detectAndDecode(arr)
return text == expected, text
if __name__ == "__main__":
img, version, ratio = build(URL)
ok, text = decodes_to(img, URL)
img.save("styled_qr.png")
print(f"version {version}, contrast {ratio:.1f}:1, decoded={text!r}, match={ok}")
if not ok:
raise SystemExit("Does not scan: shrink the logo or raise the contrast")
Output when I ran it (qrcode 8.2, Pillow 12.3, OpenCV 5.0):
version 3, contrast 13.2:1, decoded='https://example.com/menu', match=True
What breaks it: a small experiment
I changed one thing at a time and ran each version through the same OpenCV check. Same URL, rounded modules, solid square "logo" in the centre:
| Variation | Decoded by OpenCV? |
|---|---|
| Plain, level H, 4-module quiet zone | ✅ Yes |
| Logo at 22% of width, level H | ✅ Yes |
| Logo at 22% of width, level L | ❌ No |
| Logo at 35% of width, level H | ❌ No |
| Logo at 45% of width, level H | ❌ No |
| No quiet zone, placed on a striped background | ❌ No |
| 4-module quiet zone, same striped background | ✅ Yes |
| Low contrast (mid-grey on cream, 2.3:1) | ✅ Yes (see note) |
| Inverted (cream on navy) | ❌ No |
That low-contrast result is the trap. A clean digital file is the easiest test a code will ever face, so it passes. Print, glare, a cheap camera or a dim room will knock it over first. Software checks catch obvious failures. They don't stand in for scanning a printed proof with a couple of real phones.
Comparing the tools
If you'd rather not write code, here's how some popular tools deal with the same problem. I took these facts from each tool's own pages in October 2026, and prices change.
| Tool | What it's built for | How it helps scanning | Price model (Oct 2026) |
|---|---|---|---|
Python qrcode (above) |
Full control in code | You choose the EC level, quiet zone and checks | Free, open source |
| QRCode Monkey | Free static codes with logo, colours and shapes | Says codes can have up to 30% error correction, warns about contrast, and asks you to test-scan the preview | Static codes free, including for commercial use |
| QR Code Generator PRO | Dynamic codes, tracking, bulk creation | Managed platform | Paid plans after a 14-day trial; Starter from $9.99/mo billed yearly (USD, excl. VAT) |
| Uniqode (formerly Beaconstac) | Dynamic codes and analytics for teams | Managed platform | Static codes free; paid plans from $9/mo billed yearly |
| Canva QR apps | A QR code inside a design you're already making | Lets you set colours and margin; help docs advise high contrast and space around the code | Static free; dynamic codes on Free/Pro carry a Canva logo |
| QR Petz | Turning a link into a QR that's also a character (3D reveal or blocky) | "Check your QR" confirms the exact text with two software scanners | Free (nothing for sale, confirmed by Davendra 13 Oct 2026) |
A different approach: make the code the character
Most branded QR codes start with a working code and add decoration until it nearly breaks. I wanted to try it the other way round, so I built QR Petz. You give it a link and it makes a scannable QR that's also a little character (Peek, Momo, Bunny, Orbit and others). Tilt it and a 3D character appears, or watch the squares rearrange themselves into one. Before you download anything, "Check your QR" reads the code with two software scanners and confirms the decoded text exactly matches your link. You can also export an 8-second loop for social.
Checklist
- Use error-correction level H for any styled code.
- Keep the 4-module quiet zone. Never crop it.
- Dark on light, with a big contrast gap.
- Keep the logo small. In my test it scanned at 22% of the width and failed at 35%.
- Shorten the URL so the code needs fewer, bigger modules.
- Decode the final file and compare the exact text, not just "it found something".
- Print a proof and scan it with at least two phones before you print the full run.
Top comments (0)