← All posts Engineering

Why a QR Code Can Look Perfect and Scan as Nothing

A QR code with one field misplaced still looks like a QR code. Fifteen bits in the corner decide whether a scanner can read the other four hundred, and nothing about the image tells you they are wrong. Here is what those bits are and how to verify a code before it goes to the printer.

I built a QR encoder by hand for this site — a few hundred lines of JavaScript with no dependencies, because a code that gets printed onto a business card should be generated once, committed, and reviewed, not fetched from a package registry at build time.

The first symbol it produced looked completely correct. Three finder squares in the right corners, a clean timing pattern, an even scatter of dark and light modules in the data region, the right size for the string. I looked at it for a while and could find nothing wrong with it.

It scanned as absolutely nothing. Not the wrong value — no value at all. Three phones and two desktop decoders all refused to see a code there.

Fifteen bits decide whether the other four hundred are readable

A QR symbol is not just a bitmap of your data. Before a scanner can read the data region it has to know two things: which error-correction level was used, and which of the eight data masks was applied. That information lives in a 15-bit field called the format information, and it is the first thing a decoder reads.

Those fifteen bits are built like this:

5 data bits   = 2 bits error-correction level | 3 bits mask id
10 check bits = BCH(15,5) over the data bits, generator 0x537
15-bit field  = (data << 10 | check) XOR 0x5412

The XOR at the end is not decoration. Without it, the legal combination of error-correction level M and mask 0 produces fifteen zeros, which would paint a conspicuous blank patch next to the finder pattern and give decoders nothing to lock onto. Masking with 0x5412 guarantees the field always carries a mix of light and dark modules.

The field is then written into the symbol twice, so that physical damage to one corner does not take the whole code with it. And the way it is split is where I went wrong.

The axis flips halfway through

The first copy wraps around the top-left finder pattern. Bits 0 through 5 run vertically down column 8. Then bits 6, 7 and 8 turn the corner. Then bits 9 through 14 run horizontally along row 8.

That mid-field change of direction is the trap. Writing the low bits horizontally and the high bits vertically — the transposition — is an easy mistake, and here is the part that cost me an afternoon: the result is still a perfectly plausible QR code. Every module that should be dark or light is dark or light. The finder patterns are untouched. The data region is untouched. Fifteen modules tucked against one corner hold a different value than they should, and in a version-3 symbol that is fifteen modules out of roughly 1,900.

There is no visual signature. You cannot proofread a BCH code by eye, and the format field looks like noise whether it is right or wrong.

Checking it against your own encoder proves nothing

My instinct was to write a decoder to test the encoder. That instinct is wrong, and worth naming because it is such a natural move.

A transposition bug is symmetric. A decoder written from the same misreading of the spec, by the same person, on the same afternoon, reads the broken symbol back perfectly and reports success. You have built two halves of a private format that agrees with itself and with nothing else in the world.

The only test that means anything uses a decoder you did not write. Chrome ships one:

const det = new BarcodeDetector({ formats: ['qr_code'] });
const res = await det.detect(document.querySelector('img'));
console.log(res.length ? res[0].rawValue : 'NOTHING FOUND');

Serve the file over HTTP first — php -S localhost:8000 is enough. On a file:// origin the same-origin policy blocks the pixel reads this depends on, and you get an error that looks like a broken code rather than a blocked read.

An empty result array is diagnostic. A code with a damaged data region typically still decodes, because that is exactly what the error correction is for. A code that returns nothing usually failed before the data was reached, which points at the format bits or the mask.

What this means for asset tags

Most people meet QR codes through a generator that works, so none of the above matters. It starts to matter when the codes become infrastructure — a few hundred labels on equipment, each encoding a different asset, printed once and expected to work for years.

At that point a few decisions have more effect than the tooling:

  • Encode the shortest thing that works. A short path like /a/4821 that your own system resolves beats a long URL with query parameters. Fewer characters means a lower version, which means fewer and therefore larger modules at the same label size — and physical module size is what actually determines whether a scan succeeds.
  • Level M, usually. About 15% recovery, against roughly 7% for level L. Worth it for something that will be touched. But raising the level adds check codewords, which can push you to a larger version and shrink every module — so the higher setting is not automatically the more robust tag.
  • Keep the quiet zone. Four blank modules on all four sides. It is part of the specification, not margin, and designers trim it constantly because it looks like wasted label.
  • Verify every batch, not every design. One proofread code does not clear the batch. Decode all of them in a loop and assert each returns the asset ID it was built from. It is a short script and it is the only thing standing between you and discovering in six months that a run of labels was never readable.

The underlying lesson generalizes past QR codes. The bug was invisible because the output was an image, and images get reviewed by looking at them. Anything generated that will be read by a machine should be verified by a machine — and specifically not by the machine that produced it.