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:
Budget in bytes for anything that is not purely digits or the alphanumeric set.
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.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.
Decide the error correction level before measuring, and measure again if a logo is added later.
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.
Comments
Join the discussionNo comments yet. Be the first to comment.
