Skip to main content

Why the Same 45 Characters Make Three Different QR Codes: Encoding Modes as a Byte Budget

Updated 8 min readMarkdown

As of October 9, 2026

Here is a puzzle any QR generator can produce: you change one word in a link and the code comes out visibly denser. Nothing is wrong with the generator. The payload crossed a capacity boundary and the encoder moved up a version, which makes the symbol four modules wider and taller.

Where those boundaries fall depends on something most generator UIs never show: the encoding mode. This post makes it visible with a small script, then turns the result into rules you can apply before a design is locked.

Four modes, four prices

A QR symbol stores a bit stream. The standard defines several ways to turn characters into bits, and they are not equally compact. DENSO WAVE, which created the format, lists the maximum capacity of the largest symbol as 7,089 numeric, 4,296 alphanumeric, 2,953 byte and 1,817 kanji characters. Same symbol, same number of bits; the difference is the price per character.

Mode What it accepts Cost
Numeric 0–9 10 bits per 3 digits
Alphanumeric 0–9, A–Z, space and $ % * + - . / : 11 bits per 2 characters
Byte any byte 8 bits per byte
Kanji characters from Shift JIS 13 bits per character

Two details in that table do most of the damage in practice. The alphanumeric set has no lowercase letters. And byte mode charges per byte, not per character, so the cost of non-Latin text depends on how you serialized it.

Measure it

The script uses the qrcode package's create() function, which returns the symbol's version and the segments the encoder chose. Install it with npm i qrcode@1.5.4.

// modes.js — npm i qrcode@1.5.4 && node modes.js
const QRCode = require('qrcode');
const toSJIS = require('qrcode/helper/to-sjis');

function describe(label, text, options = {}) {
  const qr = QRCode.create(text, { errorCorrectionLevel: 'M', ...options });
  const modes = qr.segments.map(s => `${s.mode.id}(${s.getLength()})`).join(' + ');
  const bytes = Buffer.byteLength(text, 'utf8');
  const size = qr.modules.size;
  console.log(`${label.padEnd(16)} ${String(text.length).padStart(3)} chars ${String(bytes).padStart(3)} bytes  v${qr.version} ${size}x${size}  ${modes}`);
}

const url = 'https://example.com/menu/autumn-2026?table=12';
describe('mixed-case URL', url);
describe('upper-case URL', url.toUpperCase());
describe('digits only', '8801234567890'.repeat(3));
describe('korean', '가을 메뉴판은 여기에서 확인하세요');
describe('japanese', '秋のメニューはこちらからご覧ください');
describe('japanese (kanji)', '秋のメニューはこちらからご覧ください', { toSJISFunc: toSJIS });

console.log('');
for (const level of ['L', 'M', 'Q', 'H']) {
  describe(`URL at level ${level}`, url, { errorCorrectionLevel: level });
}

console.log('');
// How many repeats of one character fit in version 3 at level M?
function fits(unit) {
  let n = 0;
  while (QRCode.create(unit.repeat(n + 1), { errorCorrectionLevel: 'M' }).version <= 3) n++;
  return n;
}
console.log(`v3-M holds: ${fits('7')} digits | ${fits('A')} upper-case | ${fits('a')} lower-case | ${fits('가')} hangul`);

Output:

mixed-case URL    45 chars  45 bytes  v4 33x33  Byte(45)
upper-case URL    45 chars  45 bytes  v3 29x29  Alphanumeric(36) + Byte(9)
digits only       39 chars  39 bytes  v2 25x25  Numeric(39)
korean            18 chars  48 bytes  v4 33x33  Byte(48)
japanese          18 chars  54 bytes  v4 33x33  Byte(54)
japanese (kanji)  18 chars  54 bytes  v3 29x29  Kanji(18)

URL at level L    45 chars  45 bytes  v3 29x29  Byte(45)
URL at level M    45 chars  45 bytes  v4 33x33  Byte(45)
URL at level Q    45 chars  45 bytes  v4 33x33  Byte(45)
URL at level H    45 chars  45 bytes  v6 41x41  Byte(45)

v3-M holds: 101 digits | 61 upper-case | 42 lower-case | 14 hangul

Reading the output

One lowercase letter forces byte mode. The mixed-case URL is a single 45-byte segment at version 4. Uppercased, the same address splits into an alphanumeric run of 36 characters and a 9-byte tail, and fits version 3. The tail is ?TABLE=12: neither ? nor = is in the alphanumeric set, so the encoder falls back to bytes from the first character it cannot represent.

Digits are almost free. Thirty-nine digits fit in version 2. A numeric ticket ID or a GTIN is the cheapest thing you can encode.

CJK text costs three bytes a character in UTF-8. Eighteen Korean characters, three of them spaces, serialize to 48 bytes. Eighteen Japanese characters come to 54. Both need version 4, the same as a 45-character Latin URL. The last line of the output puts a number on it: version 3 at level M holds 42 lowercase letters or 14 Hangul syllables.

Kanji mode claws that back for Japanese. With a Shift JIS conversion function supplied, the same Japanese sentence encodes as 18 kanji-mode characters and drops to version 3. There is no equivalent compact mode for Hangul, so Korean text always pays the UTF-8 price.

Error correction level is the other multiplier. The 45-character URL is version 3 at L, version 4 at M and Q, and version 6 at H. Going from M to H added eight modules to each side. If a design adds a centre logo and the tool raises the level to H to compensate, the symbol grows even though the payload did not change.

Can you just uppercase the URL?

Partly. RFC 3986 says the scheme and host are case-insensitive, and that the other components are assumed to be case-sensitive unless the scheme says otherwise. So HTTPS://EXAMPLE.COM is equivalent to its lowercase form, and whether /MENU/AUTUMN-2026 reaches the same page as /menu/autumn-2026 depends on your server.

If you control the server, a route that is deliberately case-insensitive and avoids ?, = and & can stay entirely in alphanumeric mode. HTTPS://EXAMPLE.COM/M/12 is 24 characters at 5.5 bits each. That is a legitimate optimization for codes printed very small. For everything else, the simpler win is a shorter path.

The single-byte trap

Byte mode stores bytes. Some encoder libraries, including the rendering library our own generator uses, treat each character of a JavaScript string as a single byte, which corrupts anything outside Latin-1 unless you prepare the string first. The usual fix is to hand the library a string in which each character already stands for one UTF-8 byte:

const utf8 = s => unescape(encodeURIComponent(s));

const s = '가A';
console.log([...utf8(s)].map(c => c.charCodeAt(0).toString(16)).join(' '), '| length', s.length, '→', utf8(s).length);
// ea b0 80 41 | length 2 → 4

encodeURIComponent percent-encodes the UTF-8 bytes, and unescape turns each %XX back into a single character with that code. The result is a "binary string" four characters long whose character codes are the four UTF-8 bytes. unescape is a legacy function; new TextEncoder().encode(s) gives you the same bytes as a Uint8Array if your library accepts one. Either way, check a non-Latin payload by scanning it, and budget by Buffer.byteLength(text, 'utf8') or TextEncoder, never by text.length.

A budget you can apply before design starts

Using version 3 at level M (29 × 29 modules) as the target for a small print:

Payload type Fits in v3-M Typical use
Digits 101 ticket numbers, product codes
Uppercase alphanumeric 61 short codes, case-insensitive URLs
Lowercase or mixed text, ASCII 42 bytes ordinary URLs
Hangul, kana or Han in UTF-8 14 characters a few words

Rules that follow:

  1. Budget in bytes for anything that is not purely digits or the alphanumeric set.

  2. Keep query strings out of printed codes where you can. ? and = end an alphanumeric run, and tracking parameters are usually the longest part of the payload.

  3. Put non-Latin text behind a short URL instead of into the symbol, unless it is Japanese and your encoder and target scanners handle kanji mode.

  4. Decide the error correction level before measuring, and measure again if a logo is added later.

  5. Log the version in your build or export step. A jump from one version to the next is the earliest warning that a payload change will alter the artwork.

Where this shows up in a generator

We are a small team that builds free web tools, and one of the things we build is a QR code generator that runs entirely in the browser. It applies the UTF-8 conversion shown above before handing text to its rendering library, so Korean and Japanese payloads scan correctly, and it switches error correction to H when a logo is added. Both behaviours are the reason a code can change density when the visible text barely changed, which is where this post started.

The script is the more useful takeaway. Point it at your own payloads and you will know the version, and so the module count, before anyone opens a design tool.

Try payloads of different kinds and watch the symbol change: makeqr.fyi

About the author

  • I work in a small team that builds free, browser-based web tools: a QR code generator, a character counter and a few calculators. I write about the implementation details we run into.

No comments yet. Be the first to comment.