Never Use a QR Code's Decoded Value As-Is — Error Correction Succeeding Does Not Guarantee the Value

· · QR Code, Barcode, Error Correction, Input Validation, Data Quality, Business Systems, C#, Design, Field Operations

The inspection terminal in the warehouse goes “beep.” That’s the signal that the QR code on the delivery slip has been read. The decoded string is passed straight to the inventory system, the slip is allocated, and the shipping instruction is confirmed. QR codes have error correction, so even if it’s a bit dirty the correct value comes back — systems built on that premise are anything but rare.

The first half of that is true. QR codes are designed so that data can be recovered despite dirt and damage, and from error correction level L to H they can recover roughly 7% to 30% of the codewords.[^denso] The problem is the second half: the inference that “therefore the value that came back is correct.”

Using sample images of actual generated QR codes and measurements from two different decoders, this article sets out why error correction succeeding does not guarantee the value, and what a business system should validate on its side. Every QR code shown in this article is real. The readable ones can be checked directly with a QR reader you have to hand (some are included as specimens to demonstrate that they cannot be read, and those are called out where they appear). We’ve also prepared a QR code decoding comparison tool (Japanese UI) that lets you switch between jsQR and OpenCV.js in the browser. The measurements in this article can be reproduced there.

1. The Bottom Line First

  • Error correction is “recovery,” not “validation.” The standard itself states that dirty modules result in “misdecoding as an apparently valid but different codeword.”1
  • With random dirt, it essentially falls on the side of “unreadable.” Across 9,700 measured trials, there were zero cases where a wrong value was returned.
  • But when damage is concentrated in one place, you reliably get a different value. Just 7 of the 26 codewords being corrupted makes 004873 read as 104873, and two independent decoders both returned the same error.
  • Outside error correction there are much simpler pitfalls. Structured Append, character encoding, another code within the frame. All of them can happen without any error being raised. Depending on the decoder, warnings or exceptions may appear, but how they appear is implementation-dependent, and combinations that fall on the silent side genuinely exist.
  • So treat a decoded value as unvalidated input. The basic form is to receive it through three stages: format check → check digit → business validation.

2. A Sample — Two QR Codes That Look Almost Identical

First, look at the real thing. Both of the following read without any error.

The top is a normal QR code; the bottom is a QR code with a single vertical band of damage. They look almost identical

  • A (top)NO:20260725-004873
  • B (bottom)NO:20260725-104873

They’re real, so you can try them with a reader you have to hand. This slip number scheme is “8-digit order date + 5-digit sequence + 1 check digit,” so what changed is the sequence part — 00487 became 10487, pointing at a different slip 10,000 entries away.

The stock iOS camera may not react. The stock camera is built to prioritise content it can “open,” such as a URL, so for a text-only QR like the ones in this article nothing may appear. A QR reader app will read them. The same image behaving differently depending on the reader implementation — the very theme of this article turns out to be something you can experience with the first sample.

The only difference between B and A is 31 modules inside a single vertical band, columns 10 to 14 from the left. That’s 15% of the 208 modules in the codeword region.

A diagram showing, within B, the 31 modules that changed from A marked with red boxes. They are distributed in a narrow vertical strip

And the crucial part is that the decoder returns no error for either one. There isn’t even a “correction applied” notification. As far as the application is concerned, both are equally successful reads.

One caveat: this damage was deliberately constructed, and the odds of it arising by chance are not high. How it was built, and how far it is from real-world dirt, is covered in Section 4.

3. Why It Happens — Inside Error Correction

QR code error correction uses Reed-Solomon codes and operates per codeword (8-bit units). Here’s the breakdown for the version 1, error correction level M symbol (21×21 modules) used here.1

Total codewords Data codewords Error correction codewords Correction capability per the standard
26 16 10 4 codewords

The last column is what catches the eye. With 10 error correction codewords, a Reed-Solomon code can correct up to 5. Yet the correction capability defined by the standard is 4. That difference of one is deliberately set aside as misdecode protection codewords p (for version 1-M, p = 2). Table 13 of the standard also notes in a footnote that “to reduce the probability of misdecodes, the correction capability is set below half the number of error correction codewords.”1

In other words, the standard itself is designed on the premise that “error correction can produce an incorrect value.” The same clause also says this:

Because QR Code is a matrix symbology, a defect that changes a module from dark to light (or vice versa) causes the affected symbol character to be misdecoded as an apparently valid but different codeword.1

The reason lies in the correction principle itself. What Reed-Solomon decoding does is search for a codeword within a fixed distance (the correction capability) of the received pattern. If it finds one, it returns that as the answer; if it doesn’t, it ends in “unreadable.” It does not go hunting for the nearest codeword no matter how badly damaged the pattern is.

From this property, results fall into two camps. Random damage scatters far from any codeword, so it usually falls into “not found = unreadable.” What’s dangerous is when the damage happens to land in the neighbourhood of a different codeword. At that point the decoder decides that codeword is the right answer and returns it. The returned codeword is entirely self-consistent, so there’s no way to tell it’s wrong.

4. How Far Does Correction Go, and Where Does It Get Dangerous?

Here is where the measurements begin. The conclusion up front: what’s dangerous is not the “amount” of dirt but “where it lands.”

4.1. Random Dirt Falls on the Side of “Unreadable”

Against a version 1-M symbol, we flipped a number of the 208 modules in the codeword region at random and classified the results (300 trials per level, 3,900 in total).

A comparison of QR codes with 6 modules flipped and with 9 modules flipped. The top one reads; the bottom one does not

The top has 6 flipped and the bottom 9. The top reads correctly; the bottom doesn’t read at all. A difference of 3 modules is barely perceptible to the human eye. The boundary lies somewhere that doesn’t show up visually.

Modules flipped Read correctly Unreadable Wrong value
0–5 1,799 1 0
6 131 169 0
7 32 268 0
8 11 289 0
9–12 0 1,200 0

Readability breaks down between 5 and 6, and 9 or more wipes it out entirely. And not a single wrong value appeared. Damage that can’t be corrected falls on the side of “unreadable” — that’s the straightforwardly good news. The numbers were almost identical with jsQR (all 1,800 correct for 0–5; within one case of OpenCV from 6 onward).

Looking at the same thing per codeword makes the boundary even sharper (200 trials per level, 1,800 in total).

Codewords corrupted Read correctly Unreadable Wrong value
0–5 1,194 6 0
6–8 0 600 0

Up to 5 codewords are corrected. As the previous section explained, the correction capability per the standard is 4, and the remainder is an allowance for “detecting without correcting.” jsQR read all 1,200 cases from 0–5 correctly too, so both implementations spend that allowance entirely on correction. The margin the standard set aside as misdecode protection cannot be relied on at the implementation level.

We also ran 2,000 additional trials each for corruption clearly beyond the correction capability (6 codewords and 8 codewords), and again there were zero wrong values — everything ended in “unreadable.”

Camera-derived degradation shows the same tendency. Here are the results of applying uniform blur, noise, and reduced contrast across the whole symbol (200 trials each, 1,000 in total).

Blur σ Noise σ Contrast Read correctly Unreadable Wrong value
0 0 1.00 200 0 0
1.5 10 0.90 183 17 0
3.0 20 0.70 1 199 0
4.5 30 0.50 0 200 0
6.0 40 0.35 0 200 0

The outcome is either “reads correctly” or “doesn’t read” — there’s no middle ground. As degradation progresses the success rate drops, but everything lost turns into a read failure.

That said, this is with uniform degradation. Real camera shake has a direction, and shooting at an angle or uneven lighting corrupts only part of the image. As we’ll see next, what’s dangerous is damage being biased, so don’t generalise this into “image quality problems don’t cause misreads.”

4.2. When It Lands Badly, You Reliably Get a Different Value

Sample B in Section 2 was deliberately constructed as that kind of “biased damage.”

Line up the 26 codewords of NO:20260725-004873 (A) and NO:20260725-104873 (B) and they differ in 12 places: two data codewords, plus the ten error correction codewords dragged along with them.

Move 7 of those 12 places toward B’s values and the resulting pattern sits 7 codewords away from A and 5 codewords away from B. If the correction capability is 5, the decoder interprets this as “B with 5 places damaged” and corrects it to B.

Condition Combinations tried Number misread as B
7 codewords moved toward B (distance 5 from B) 792 792 (100%)
8 codewords moved toward B (distance 4 from B) 495 495 (100%)

Every single combination was misread, without exception. The lower row is at distance 4 from B — that is, inside the correction capability defined by the standard. Even an implementation that respects the misdecode protection codewords p gives the same result once the damage grows by one codeword. p only lowers the probability; it doesn’t prevent anything.

The B shown in Section 2 is the combination among these where the damage groups into a single vertical band (31 modules). Minimised, it can be brought down to 23 modules. Two unrelated implementations, OpenCV 5.0.0 and jsQR 1.4.0, both return NO:20260725-104873.

4.3. How to Take This Result

To be honest, this kind of damage is unlikely to arise at random. With haphazard dirt, 9,700 trials produced zero misreads. This isn’t a “it could happen tomorrow” story.

Even so, there are three reasons it can’t be ignored.

  • Real-world damage isn’t random. Creases run in straight lines, abrasion from conveyance concentrates on the same edge, and a clogged print head produces vertical streaks. The band-shaped damage used here is one example of this kind of “positionally biased damage.” That said, B involves changes in both directions — 17 modules white→black and 14 black→white — so a defect that merely removes ink can’t reproduce it. Both directions occurring together requires something like the shadow of a crease shifting the binarisation threshold, dirt and faded printing overlapping, or another label being partially stuck over the top.
  • The number of scans is orders of magnitude larger. A probability negligible on any single scan is a different story at a site that reads tens of thousands of times a day. And because a misread raises no error, it leaves no record, and ends up processed as an inventory discrepancy of unknown origin.
  • Being able to create it deliberately means other people can create it too. The pattern here was constructed mechanically after picking a target value. For QR codes where there’s a motive to alter them — price tags, coupons — this becomes an attack technique.

5. “It Read, but It’s Wrong” With Nothing to Do With Error Correction

These are the ones you’ll actually hit more often in practice. They happen even when error correction is working perfectly, and they aren’t even a matter of probability.

5.1. Reading Only the First Symbol of a Structured Append Set

QR codes have a mechanism (Structured Append) for splitting long data across several symbols and having the reader concatenate them. The following is the first of three symbols into which NO:20260725-004873/LOT:AB-77/QTY:120/EXP:20270131 was split.

The first of three Structured Append QR symbols. Read on its own, it returns a slip number with the tail missing

It looks like an ordinary QR code, with no clue that it’s one of three. Read on its own, OpenCV returns this:

NO:20260725-00487

No error, no warning. A completely plausible-looking slip number, with only the trailing check digit 3 dropped. The second and third symbols come out as 3/LOT:AB-77/QTY: and 120/EXP:20270131 respectively. Passing the same image to jsQR returned an empty string. What happens when an application that isn’t expecting Structured Append happens to scan the first symbol depends on the decoder.

5.2. Character Encoding and ECI

Next is a QR code containing 部品番号 東-004873 in Shift_JIS with no ECI designation.

A QR code generated in Shift_JIS with no ECI designation. Reading it produces garbled text

This one is real too. Read it with a reader you have to hand and you’ll get either 部品番号 東-004873, a garbled string, or nothing at all — which tells you which interpretation your reader is using.

Feed this image to the QR code decoding comparison tool and you’ll see the raw byte sequence jsQR extracted alongside the results of reinterpreting it as UTF-8, Shift_JIS, EUC-JP, and so on. You can see, right there, how the same byte sequence becomes something completely different depending on the character encoding.

Here are the results of generating the same content with varying character encodings and ECI designations and feeding them to the two decoders. All four of these symbols are included among the tool’s samples. Note, though, that the values in the table were measured with Python’s cv2, and the third row alone differs from the browser version (discussed in detail immediately after the table). What the tool lets you confirm is the browser version’s behaviour, so reproducing the third row’s “dies with an exception” requires Python’s cv2.

Generation condition OpenCV 5.0.0 jsQR 1.4.0
Shift_JIS / no ECI Returns a garbled string as success Empty string
UTF-8 / no ECI 部品番号 東-004873 部品番号 東-004873
Shift_JIS / with ECI Emits a warning and fails to decode Empty string
UTF-8 / with ECI 部品番号 東-004873 部品番号 東-004873

The first row is the worst. OpenCV interpreted the byte sequence as Latin-1 and returned a broken string, \x95\x94\x95i..., with no error at all. From the application’s point of view that’s a normal read, and if it goes straight into the database you’ve created one garbled record.

This isn’t an OpenCV bug — it’s behaviour exactly in line with the default interpretation (ISO/IEC 8859-1) defined by the current standard.2 What departs from the standard is putting Shift_JIS in without ECI.

The third row is not to be overlooked either. Despite ECI (the mechanism for explicitly declaring the character encoding) being specified correctly, OpenCV emitted the warning QR: ECI is not supported properly and then died with an exception, unable to interpret the return value as UTF-8. The inversion where the QR built faithfully to the standard is the one that can’t be read genuinely happens.

What’s more, this third row produces different results even within the same OpenCV depending on the language binding. The table above is the result with Python’s cv2, but pass the same image to the browser version (opencv.js 5.0.0) and no exception is raised — it returns as a success the string ���i��� ��-004873, full of replacement characters. That’s because when Emscripten converts std::string as UTF-8, it drops invalid bytes to U+FFFD rather than throwing. Same version, same image; the only thing that changed is the calling language. Python, where an exception at least lets you notice, is the better of the two; the browser version says “it read” while handing back a broken value. You can confirm this yourself with the tool’s samples.

5.3. Several QR Codes Within the Frame

Several QR codes printed on one slip, or the label on the next box being in the field of view — commonplace situations. We tried three QR codes lined up side by side.

An image with three QR codes side by side. From the left: NO:, ITEM:, LOT:

First, on this image OpenCV’s single-read API returned nothing at all. But that is not a guarantee that “it rejects the input when there are several.” The single-read API is only documented as detecting and decoding one QR code; nothing says it rejects multiple symbols, and depending on the layout it could well return one of them. Do not use the presence or absence of a return value from the single-read API as a substitute for detecting multiple codes.

So what about the multi-read API? This is where it got awkward.

Here are the results of taking the image above (NO: / ITEM: / LOT: from the left) as-is and varying only the pixel dimensions. This corresponds to a real scanner where the distance to the target or the camera resolution changes.

Image width Return order
1,001 px NO: / LOT: / ITEM:
1,502 px Nothing returned
2,002 px NO: / LOT: / ITEM:
3,003 px NO: / ITEM: / LOT:
4,004 px LOT: / NO: / ITEM:

Same image, yet just changing the resolution changes the order. It’s neither left to right nor largest first. There’s even a resolution at which nothing reads. The order is decided by the internal workings of the detection algorithm, and since it isn’t specified anywhere, this is what you get.

Which means that code doing “the first one should be the slip number” and using index 0 may happen to work today, but will grab a different code tomorrow just because someone moved the camera closer. It’s the kind of bug that’s hard to reproduce and hard to trace.

The receiving side has to select by content, not by order. Pick up all of them with the multi-read API, accept only the ones whose prefix and format match, and raise an error if there are zero matches or two or more — that’s the safe way to write it.

5.4. The Contents May Not Be Correct in the First Place

There’s a layer image processing fundamentally cannot detect. The source data that was printed is wrong; an old label from before a relabelling is still on the box; a label from a different trading partner has got mixed in; the label is a photocopy.

A QR code only tells you what is written there. Whether it is correct, and whether your own organisation issued it, can only be confirmed by the party receiving it.

6. How to Handle the Value You Received

The countermeasure comes down to stacking your own validation outside the error correction.

Stage What it validates What it catches
1. Format check Length, character classes, separators, exact prefix match Reading a different code, a Structured Append fragment, garbled text
2. Self-validation Check digit Reliably detects single-character changes; misses some multi-character ones
3. Business validation Master data lookup, and whether it matches what should be processed right now Old labels, another company’s labels, picking up the wrong slip

The sample slip number is NO: + 8-digit order date + 5-digit sequence + 1 check digit, with the last digit using the same modulus 10, weight 3 scheme as GS1. The misread NO:20260725-104873 from Section 2 stops here, because the correct check digit for 2026072510487 is 0, which doesn’t match the 3 on the label.

using System.Linq;
using System.Text.RegularExpressions;

public sealed record ScanOutcome(bool Accepted, string? SlipNo, string Reason);

public static class SlipScanValidator
{
    // NO: + 8-digit order date + '-' + 5-digit sequence + 1 check digit
    // Terminate with \z, not $. In .NET, $ also matches just before a trailing
    // newline, so it would let "NO:20260725-004873\n" through.
    // Use [0-9] rather than \d for digits. In .NET, \d matches Unicode digits in
    // general, including full-width digits, but the check digit calculation
    // downstream assumes ASCII
    private static readonly Regex Format =
        new(@"\ANO:(?<date>[0-9]{8})-(?<seq>[0-9]{5})(?<cd>[0-9])\z", RegexOptions.Compiled);

    public static ScanOutcome Validate(string? raw, ISlipRepository repo)
    {
        // 1. Don't turn "couldn't read" into "an empty success".
        //    Decoders differ in whether they signal failure with null, an empty string, or an exception
        if (string.IsNullOrEmpty(raw))
            return new(false, null, "Could not read the code. Please scan again");

        // 2. Format check. Check for an exact match including length, not a prefix match.
        //    The Structured Append fragment "NO:20260725-00487" is rejected here
        var m = Format.Match(raw);
        if (!m.Success)
            return new(false, null, $"Not in the slip QR format ({Describe(raw)})");

        // 3. Self-validation. If a misdecode has corrupted a digit, it's caught here
        var body = m.Groups["date"].Value + m.Groups["seq"].Value;
        if (Modulus10Weight3(body) != m.Groups["cd"].Value[0] - '0')
            return new(false, null, "The check digit does not match. Please check the label for dirt");

        // 4. Business validation. Does it exist, and is it in a state we may process now?
        var slip = repo.Find(raw);
        if (slip is null)
            return new(false, null, "No matching slip found");
        if (slip.Status != SlipStatus.WaitingForShipment)
            return new(false, null, $"This slip is \"{slip.Status}\". It is not due for shipment");

        return new(true, raw, "OK");
    }

    // The same modulus 10, weight 3 as GS1. Apply weights 3,1,3,1... from the right-hand end
    private static int Modulus10Weight3(string body)
    {
        var sum = 0;
        for (var i = 0; i < body.Length; i++)
        {
            var weight = (body.Length - i) % 2 == 1 ? 3 : 1;
            sum += (body[i] - '0') * weight;
        }
        return (10 - sum % 10) % 10;
    }

    // A decoded value is external input. Keep only printable ASCII before putting it
    // on screen or in a log. char.IsControl only drops Unicode Control (Cc) and lets
    // Format (Cf) characters such as U+202E (right-to-left override) through.
    // Write it as an allow list, not a deny list
    private static string Describe(string raw)
    {
        var kept = raw.Where(c => c >= ' ' && c <= '~').Take(40).ToArray();
        if (kept.Length == 0) return "(string cannot be displayed)";
        var safe = new string(kept);
        return kept.Length < raw.Length ? safe + "…(some characters removed)" : safe;
    }
}

The design of check digits themselves — the formulas and how to choose one — is organised in Code Design for Business Systems — Deciding Product and Customer Codes, and Check Digits. Their value in the context of this article is that the check works not only against human typing errors but against machine misreads.

What These Three Stages Don’t Protect

Even with all three stages in place, there are three holes. All of them can be closed by “taking the validation design one level deeper,” so make sure you understand them too.

Check digits miss multi-character changes. What modulus 10, weight 3 reliably catches is a single-character error. In fact, 2026072500487 and 2026072517487 both produce a check digit of 3, so NO:20260725-174873 sails straight through stage 2. A misdecode doesn’t necessarily change just one character, so you can’t skip stage 3.

Don’t let master data lookup stop at “does it exist.” The repo.Find() call and the status check in the code above only confirm that “a usable slip exists somewhere.” If a worker scans the label on the next box, that label also satisfies the format, the check digit, and the waiting-for-shipment status, so it passes straight through. The decoded value has to be cross-checked against the item you are supposed to be processing right now — does it match the next entry on the picking list, is it linked to the container ID already scanned, is the destination the same as the run being worked? What you cross-check against differs by business process, so this alone can’t be generic code.

Duplicate processing can’t be prevented by validation. If two terminals scan the same label at almost the same moment, both confirm “waiting for shipment” before updating the status, so both pass. Preventing that is the job of the execution side: make the conditional state transition — something like UPDATE ... WHERE status = 'WaitingForShipment' — a single atomic operation, or absorb re-execution with an idempotency key. Validation is a decision at the entrance; it is not a substitute for concurrency control.

“Accidents” and “Attacks” Are Different Problems

What the three stages above protect against is accidents. Misreads from dirt, missing a Structured Append symbol, garbled text, an old label getting mixed in — errors without malice are stopped by this.

They are not a defence, however, for uses where there is a motive to alter the code (price tags, coupons, event tickets, payments). An attacker can freely produce a QR that satisfies the format, recomputes the check digit, and points at a different number that genuinely exists. Master data lookup only checks existence, so it passes straight through.

If possessing the QR implies value or authority, you need to give the value itself authenticity. Either make it an unguessable token issued by the server (a sufficiently long random value) so that one number can’t be inferred from another, or attach a keyed MAC or digital signature to the payload so the receiving side can verify it with a key. In either case, manage the used/unused state on the server to prevent duplicates being used twice.

But authenticity alone doesn’t stop substitution. Peel a genuine QR off a cheap product and stick it on an expensive one, and the token or signature is still authentic. Where the medium is reused, as with price tags that can be swapped over, the used/unused check doesn’t help either. What works here is the same idea as stage 3: confirming that the QR belongs to the item in front of you through a route separate from the value — obtaining the product’s identity by another means and cross-checking, matching against the transaction context (the till receipt, the admission time window), or physically binding them with tamper-evident labels that tear when removed.

Neither check digits nor master data lookups guarantee anything about authenticity. And authenticity itself doesn’t guarantee that the value belongs to the item in front of you. The key point is not to try to cover “misread protection,” “forgery protection,” and “substitution protection” with the same mechanism.

7. Things to Decide on the Operational Side

There are parts that code alone can’t close.

  • Always print a human-readable string beneath the QR. This is the same idea as GS1’s HRI (Human Readable Interpretation).3 When a misread is suspected, it leaves a means for a person to cross-check. In real operations it’s not unusual for this to be the only means of discovery. On-site operational practice for barcodes in general is summarised in GS1 Barcode Standards: The Basics and Operational Pitfalls.
  • Decide the procedure for when it “won’t read.” The maximum number of rescans, the fallback to manual entry, and who approves it. Leave this vague and the site drifts toward “keep changing the angle until it reads” — which pushes the misread probability up.
  • Log the values you reject. If they cluster on particular labels or terminals, you can spot a faulty printer or scanner early. But don’t write the raw string straight into a line-oriented log. Values containing newlines or control characters can forge log lines or break the display. Truncate and escape the original before storing it in a structured log field or a database column, and emit only a sanitised representation on lines humans read — build this separation in from the start. Where possible, keep the scanned image too.
  • Don’t use Structured Append. If the data doesn’t fit, raise the version, or put only an identifier in the QR and pull the rest from master data. The latter also lets you make the label smaller, and allows content corrections without reissuing the label.
  • Solve character encoding by “not putting it in.” Keep business-use QR codes within ASCII. Byte mode with no ECI designation declares no character encoding, and the default interpretation has changed between editions of the standard.2 Simply writing UTF-8 doesn’t get you interoperability, and a scanner using a different interpretation will produce garbled text. If you absolutely must include non-ASCII, declaring UTF-8 with an ECI designation is the correct answer per the standard — but as Section 5.2 shows, implementations with dubious ECI handling really do exist, so there’s no escaping verification on the actual target hardware.
  • Insert a confirmation step before irreversible operations. For operations that are expensive to undo — confirming a shipment, decrementing stock, clearing a payment — display the product name or amount looked up from the decoded value and show it to a person. A misread may look plausible as a value while looking distinctly odd in the business context.

How far to take validation is determined by the damage when you get it wrong.

Use case Format check Check digit Master data lookup Human confirmation
Internal locations and shelf numbers Required Optional Recommended Not needed
Goods in/out and stocktaking Required Recommended Required Not needed
Shipment confirmation and stock decrement Required Required Required Recommended
Invoicing and payment clearing Required Required Required Required
Preventing mix-ups of pharmaceuticals and hazardous goods Required Required Required Required

8. Summary

QR code error correction is a mechanism for recovering the original codewords from a printed pattern. It does that job properly. Our measurements bore that out: against random dirt and uniform image degradation, it read correctly within the range it could correct, and honestly gave up reading beyond it.

But that is a separate matter from the string the application received being correct in business terms. Depending on where the damage lands, correction can operate and produce a different, valid value. And as for Structured Append, character encoding, and another code within the frame — those are problems outside error correction, and aren’t even matters of probability.

That the standard itself writes “misdecoded as an apparently valid but different codeword” and goes to the trouble of reserving misdecode protection codewords captures this structure succinctly. Even with all that, the probability can only be lowered — that is as far as the standard can go. Only the application receiving the value can close the remaining gap.

A decoded value is unvalidated input arriving from outside. Treat it exactly like a string typed in at a keyboard — that, we believe, is the right way to get along with QR codes.


Verification Environment

Sections 2 to 4, which look at error correction behaviour, use version 1-M (21×21 modules) throughout. The samples in Section 5 vary in version according to the data volume, so they’re noted per section.

Item Details
Decoder 1 OpenCV 5.0.0 cv2.QRCodeDetector
Decoder 2 jsQR 1.4.0 (Node.js 22)
Generation segno 1.6.6 / Python 3.11
Symbols in Sections 2–4 Version 1-M / 21×21 (26 codewords = 16 data + 10 error correction)
Section 5.1 (Structured Append) All three symbols version 1-M / 21×21
Section 5.2 (character encoding and ECI) Shift_JIS is version 2-Q, UTF-8 is version 2-M (both 25×25), because 18–23 bytes of Japanese don’t fit in version 1-M’s 16 data codewords
Section 5.3 (multiple codes) NO: and ITEM: are version 1-M; LOT:AB-77 is version 1-H because the data is short and segno raises the error correction level (all 21×21)
[^denso]: DENSO WAVE Incorporated, [Error correction feature QRcode.com](https://www.qrcode.com/en/about/error_correction.html)
  1. ISO/IEC 18004 Information technology — Automatic identification and data capture techniques — QR code bar code symbology specification. The error correction capacity formula e + 2t ≦ d - p, the values of the misdecode protection codewords p, and the statement about “misdecoded as an apparently valid but different codeword” are in 8.5.1 Error correction capacity; version 1-M’s (26,16,4) and the footnote that “the correction capability is set below half the number of error correction codewords in order to reduce the probability of misdecodes” are in Table 13 (quotations are based on the 2000 edition). The current edition is ISO/IEC 18004:2024 2 3 4

  2. The default interpretation in byte mode when no ECI is designated has changed between editions of the standard. ISO/IEC 18004:2000 clause 8.3.1 specified “The default interpretation for QR Code is ECI 000020 representing the JIS8 and Shift JIS character sets,” but from the 2006 edition (QR Code 2005) onward the default is ECI 000003, that is, ISO/IEC 8859-1. Which also means that relying on an undeclared default leaves the interpretation liable to change purely because the edition of the standard changed 2

  3. GS1, GS1 General Specifications 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

If I set the error correction level to H, does that prevent misreads?
No. Raising the level increases the amount of damage that can be corrected, but it does not eliminate the phenomenon of "correction kicks in and produces a different value." Reed-Solomon decoding works by searching for a codeword within the correction capability of the received pattern, so if the damage happens to fall in the neighbourhood of a different codeword, it returns that one as the answer (damage that lands far away simply ends in "unreadable"). ISO/IEC 18004 states explicitly that this results in a symbol character being "misdecoded as an apparently valid but different codeword," and reserves a separate allowance of codewords for misdecode protection — but that too is a measure that lowers the probability, not a guarantee. The only place a misread can be caught is in the application that receives the value.
Realistically, how often do QR misreads happen?
With random damage, essentially never. In the measurements for this article, across 3,900 trials that flipped modules at random and 5,800 trials that corrupted codewords at random, there were zero cases where a wrong value was returned. Damage beyond what can be corrected falls on the side of "unreadable." However, real-world damage is not random: creases, abrasion, and clogged print heads all have a positional bias. Furthermore, missing a Structured Append symbol or picking up the wrong code among several isn't even a question of probability — given the right conditions it happens every single time.
QR codes have error correction, so do I still need a check digit?
Yes. They protect different layers. Error correction deals with consistency inside the symbol, and it only guarantees recovery up to the limits of its correction capability. Beyond that, damage can be "recovered" into a different valid codeword. A check digit, by contrast, checks whether the string the application received is well-formed as a code scheme. It reliably catches a single-character change, but misses some cases where multiple characters change. A modulus 10 check value has only ten possibilities, and this article gives an example where a two-digit change still matches the check digit. So treat the check digit as the layer that stops the majority of misreads, and leave the final judgement to master data lookups and business-level cross-checking. One advantage is that the same check also protects manual entry, transcription, and imports arriving by other routes.
My phone's camera app read it, so surely the value is correct?
"It read" means only that the decoder returned a non-empty string; it says nothing about whether the contents are correct. How failure is signalled also differs by implementation — an exception, an empty string, or null are all possible — so using "no exception was thrown" as a success criterion is dangerous too. In the measurements for this article there were several cases where OpenCV and jsQR returned different results for the same image. For a QR containing Japanese text in Shift_JIS, one returned a garbled string as a success while the other returned an empty string. Results also diverged on the first symbol of a Structured Append set. Whether it read tells you nothing about whether the value is right.
Should we avoid Structured Append (split QR) in business use?
Unless there is a specific reason, avoiding it is the safer choice. Structured Append is a mechanism where data is split across several symbols and the reader collects and concatenates them, but the behaviour when a decoder that doesn't support it is given only the first symbol is implementation-dependent. In the measurements for this article, OpenCV returned a truncated slip number with no error, while jsQR returned an empty string. Since implementations exist that will pass a fragment through as a plausible-looking value, if you aren't expecting Structured Append you need validation that rejects incomplete results. If the data doesn't fit, it's safer to raise the QR version, or shorten the code and lean on master data lookups instead.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog