Generate a QR Code in Java Using ZXing
Published Updated Java 12 min read
Encoding and decoding QR codes with ZXing: the quiet zone that decides whether a scanner sees your code, choosing an error-correction level, serving one from a Spring endpoint, and why a QR code can never be trusted.
ZXing (“zebra crossing”) is the reference open-source barcode library on the JVM, and generating a QR code with it is about six lines. The parts worth more attention are the two encoding hints that decide whether a phone can actually read the result, and the fact that a QR code carries no authentication whatsoever, which matters more than the API.
Written against ZXing 3.5 and Java 17.
Dependencies
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
core is the encoder and decoder, with no dependency on any imaging library. javase adds the
BufferedImage bridge, MatrixToImageWriter and BufferedImageLuminanceSource. On Android you would
take core plus the Android module instead, which is why the split exists.
Generating one
import com.google.zxing.*;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
public final class QrCodes {
private QrCodes() { }
public static byte[] png(String content, int size) throws WriterException, IOException {
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M);
hints.put(EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name());
hints.put(EncodeHintType.MARGIN, 2);
BitMatrix matrix = new QRCodeWriter()
.encode(content, BarcodeFormat.QR_CODE, size, size, hints);
ByteArrayOutputStream out = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(matrix, "PNG", out);
return out.toByteArray();
}
}
Files.write(Path.of("qr.png"), QrCodes.png("https://example.com/n/42", 300));
A BitMatrix is the abstract result, a grid of booleans. Writing it to PNG is a separate step, which
is what lets you render it as SVG or draw it into a larger composition instead.
PNG, not JPEG. A QR code is hard-edged black and white; JPEG’s lossy compression blurs module boundaries and produces codes that scan unreliably at small sizes. PNG is both smaller and correct here.
The two hints that matter
MARGIN is the quiet zone, measured in modules, and it is not decoration. The QR specification
requires four modules of clear space around the symbol; scanners use it to locate the code’s edges.
ZXing’s default is 4, and the most common cause of “my QR code will not scan” is a layout that crops it
to zero: a code placed flush against a coloured background or a border.
Setting MARGIN to 0 or 1 to save space is a false economy. Two is a practical minimum when you
control the surrounding background; leave the default when you do not.
ERROR_CORRECTION trades capacity for damage tolerance:
| Level | Recoverable | Use for |
|---|---|---|
L | ~7% | clean digital display, maximum data |
M | ~15% | the default choice; screens and good print |
Q | ~25% | print that may scuff, or a small logo overlay |
H | ~30% | industrial labels, larger logo overlay |
Higher correction means more modules for the same content, so the code becomes denser and each module
smaller at a fixed pixel size — which can make it harder to scan. H is not “better”, it is a
different trade. M unless you have a reason.
That overlay case is worth knowing about: a logo in the centre works because error correction can reconstruct the covered modules. Keep the covered area well under the level’s budget, and never cover a corner — the three large squares are the finder patterns, and a scanner cannot locate the code without them.
CHARACTER_SET should be set explicitly. Without it, ZXing picks an encoding and a decoder may
guess differently for non-ASCII content, so accented characters come back mangled. UTF-8 both ways.
Size and capacity
new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 300, 300, hints);
The width and height are a request, not the exact output. QR codes are built from a whole number of modules, so ZXing scales to the nearest multiple that fits — asking for 300×300 for a 33-module symbol gives you 297 or 330, not 300. Do not build a layout that assumes an exact pixel size.
Capacity depends on the encoding mode and correction level. At the largest version (40) with level L,
the theoretical maxima are roughly 7,089 digits, 4,296 alphanumeric characters or 2,953 bytes. Those
are ceilings, not targets: a symbol near them has 177×177 modules, and at any realistic print size each
module is too small for a phone camera.
The practical guidance is to keep the payload short. For a URL that means a short path, not a query string with tracking parameters — every character makes the code denser and harder to scan. If the content is long, encode a short identifier and look the rest up.
Reading one back
public static String decode(byte[] png) throws IOException, NotFoundException {
BufferedImage image = ImageIO.read(new ByteArrayInputStream(png));
LuminanceSource source = new BufferedImageLuminanceSource(image);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
hints.put(DecodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name());
return new MultiFormatReader().decode(bitmap, hints).getText();
}
HybridBinarizer converts greyscale to black and white and handles uneven lighting far better than
GlobalHistogramBinarizer — it is the right default for a photograph. TRY_HARDER spends more time
looking, which is worth it for camera input and unnecessary for an image you generated.
decode throws NotFoundException when there is no code in the image. That is an expected outcome for
user-supplied input, not an error condition — catch it and report “no code found”.
A decode round trip is the test worth having, because it catches a broken quiet zone or encoding mismatch that eyeballing the image will not:
@Test
void roundTrips() throws Exception {
String content = "https://example.com/n/42?ref=café";
assertThat(QrCodes.decode(QrCodes.png(content, 300))).isEqualTo(content);
}
Serving one from Spring
@RestController
@RequestMapping("/api/qr")
public class QrController {
@GetMapping(produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> generate(
@RequestParam @NotBlank @Size(max = 512) String content,
@RequestParam(defaultValue = "300") @Min(100) @Max(1000) int size)
throws Exception {
byte[] png = QrCodes.png(content, size);
return ResponseEntity.ok()
.contentType(MediaType.IMAGE_PNG)
.cacheControl(CacheControl.maxAge(Duration.ofDays(30)).cachePublic())
.eTag(Integer.toHexString(Objects.hash(content, size)))
.body(png);
}
}
Bound both parameters. Without a @Max on size, a request for 20000 allocates a 400-megapixel image
— an unauthenticated memory exhaustion in one query parameter. Without a @Size on content, an
oversized payload either throws or produces an unscannable 177-module symbol.
The output is deterministic for a given input, so it caches indefinitely. An ETag plus a long
max-age means most requests never reach your encoder.
Scalable output
BitMatrix is a grid, so rendering it as SVG is a loop rather than a library:
public static String svg(String content, int module) throws WriterException {
BitMatrix m = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 0, 0);
int w = m.getWidth(), h = m.getHeight();
StringBuilder sb = new StringBuilder()
.append("<svg xmlns=\"http://www.w3.org/2000/svg\" ")
.append("width=\"").append(w * module).append("\" ")
.append("height=\"").append(h * module).append("\" ")
.append("viewBox=\"0 0 ").append(w).append(' ').append(h).append("\">")
.append("<rect width=\"100%\" height=\"100%\" fill=\"#fff\"/>");
for (int y = 0; y < h; y++) {
for (int x = 0; x < w; x++) {
if (m.get(x, y)) {
sb.append("<rect x=\"").append(x).append("\" y=\"").append(y)
.append("\" width=\"1\" height=\"1\" fill=\"#000\"/>");
}
}
}
return sb.append("</svg>").toString();
}
Passing 0 for width and height gives the natural module count with no scaling, which is what you want when the SVG viewBox handles sizing. Worth it for print, where a raster at the wrong DPI is the usual cause of a code that will not scan.
A QR code cannot be trusted
The part that matters more than any of the API above.
A QR code is text with no authentication. There is no signature, no origin, nothing that binds the content to whoever printed it. Anyone can generate a code encoding any URL, print it on a sticker, and place it over yours. That attack has a name — quishing — and it is common on parking meters, restaurant tables and payment posters, because it needs no technical skill at all.
So: never treat a scanned value as authorisation. Two rules follow.
Validate anything you decode, exactly as you would an untrusted URL parameter:
URI uri = URI.create(decoded);
if (!"https".equals(uri.getScheme()) || !ALLOWED_HOSTS.contains(uri.getHost())) {
throw new UntrustedQrCodeException(decoded);
}
And when a code grants something — a ticket, a discount, a door — encode a short-lived, signed token rather than the grant itself:
// wrong: possessing the code IS the entitlement, forever
String content = "https://example.com/redeem?voucher=SAVE20";
// better: a signed, expiring reference the server verifies and can revoke
String content = "https://example.com/r/" + tokenService.issue(voucherId, Duration.ofMinutes(10));
The server checks the signature, the expiry and whether it has already been redeemed. The code becomes a pointer, and pointers can be invalidated. A raw voucher code in a QR image is a bearer credential that anyone who photographs it now owns.
Frequently asked questions
Why will my QR code not scan?
Most often the quiet zone. Keep EncodeHintType.MARGIN at 2 or more
and ensure the layout does not crop it. After that: too much content for the physical size, or a lossy
image format.
PNG or JPEG?
PNG. JPEG’s lossy compression blurs the hard module edges and produces unreliable scans at small sizes.
Which error correction level should I use?
M unless you have a reason. Higher levels tolerate more
damage but pack more modules into the same space, making each one smaller — sometimes harder to scan,
not easier.
Can I put a logo in the middle?
Yes, that is what error correction allows. Stay well inside the level’s budget and never cover a corner — the three large squares are finder patterns.
Why is the output not the size I asked for?
The dimensions are a request. QR codes contain a whole number of modules, so ZXing scales to the nearest fitting multiple.
How much data fits?
At the largest version with level L, roughly 7,089 digits or 2,953 bytes —
but such a symbol has 177×177 modules and is impractical to scan. Keep payloads short and encode a
reference for anything long.
Why do accented characters come back wrong?
The character set was not specified. Set
CHARACTER_SET to UTF-8 on both encode and decode.
Which binarizer should I use for photos?
HybridBinarizer. It handles uneven lighting far better
than GlobalHistogramBinarizer. Add TRY_HARDER for camera input.
Do I need the javase artifact?
Only for the BufferedImage bridge — MatrixToImageWriter and
BufferedImageLuminanceSource. core alone encodes and decodes if you handle pixels yourself.
Is a QR code secure?
No. It carries no signature and no origin, so a sticker can replace it. Validate every decoded value, and encode a short-lived signed token rather than the entitlement itself.
Where should I go next?
The Java guides cover the standard library these examples use, and the Spring Boot REST API guide covers the endpoint pattern the controller follows.