Shipping labels, warehouse bin labels, product barcodes — in logistics they overwhelmingly come out of one family of hardware: Zebra label printers and the many compatibles that emulate them. And those printers speak ZPL (Zebra Programming Language).
Like ESC/POS for receipt printers, ZPL is not an image format. It's a compact text language: you describe what goes where on the label, and the printer renders it at full speed. A 4×6" shipping label described in ZPL is ~500 bytes; the same label as a bitmap is hundreds of kilobytes and prints slower.
The mental model: dots on a canvas
Every ZPL label lives between ^XA (start) and ^XZ (end). Positions are in dots, and dots depend on the printhead's DPI:
- 203 dpi (the common default) → 8 dots/mm → a 4×6" label is 812 × 1218 dots
- 300 dpi → 12 dots/mm → the same label is 1218 × 1824 dots
This matters: ZPL written for a 203 dpi printer prints two-thirds size on a 300 dpi one. Decide your target DPI up front.
A real shipping label
^XA
^PW812
^LL1218
^CI28
^CF0,45
^FO40,40^FDPrintBase Logistics^FS
^FO40,100^GB732,3,3^FS
^CF0,30
^FO40,140^FDSHIP TO:^FS
^CF0,38
^FO40,185^FDJane Smith^FS
^FO40,235^FD1 Market Street^FS
^FO40,285^FDSan Francisco, CA 94105^FS
^FO40,420^BY3
^BCN,160,Y,N,N
^FD1Z999AA10123456784^FS
^FO560,140^BQN,2,7
^FDQA,https://example.com/track/1Z999^FS
^XZLine by line, the commands you'll use constantly:
| Command | What it does |
|---|---|
^PW / ^LL | Print width / label length, in dots |
^FO x,y | Field origin — position the next element |
^CF0,45 | Default font and size (font 0 is the scalable one) |
^FD ... ^FS | Field data — the actual text — and field separator |
^GB w,h,t | Graphic box — here, a horizontal rule |
^BCN,160,Y,N,N | Code 128 barcode, 160 dots tall, human-readable line on |
^BQN,2,7 | QR code, magnification 7 (QA, prefix sets error correction + auto mode) |
^CI28 | UTF-8 text encoding — set it and accented names stop printing as garbage |
Preview without burning labels
The single best ZPL development tip: the Labelary viewer (labelary.com) renders ZPL to PNG in the browser, and it has a free API — POST your ZPL, get an image back. Wire it into tests and you'll catch a misplaced barcode in CI instead of on a roll of wasted labels.
Getting ZPL into the printer
Direct TCP. Zebra printers listen on port 9100; netcat or a Node socket can write ZPL straight to them. Works great — until the printer is in a warehouse behind NAT and your backend is in the cloud.
The Windows driver. Drivers rasterize your document into an image and throw away everything that makes ZPL good: speed, crisp barcodes at exact module widths, small payloads. Use the driver for office documents, not label workflows.
Cloud RAW passthrough. A lightweight agent next to the printer holds an outbound connection to the cloud; your backend sends the ZPL through an HTTPS API, and the agent writes it to the printer byte-for-byte. ZPL is plain ASCII, so with PrintBase the whole thing is:
const zpl = buildLabel(order); // the template above, filled in
const res = await fetch('https://api.printbase.cloud/v1/print-jobs', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PRINTBASE_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
printer_code: 1000000, // the warehouse Zebra's code
content_type: 'raw',
content: Buffer.from(zpl).toString('base64'),
}),
});
const job = await res.json();
// { id: "job_abc", status: "queued" }Your WMS creates the shipment, calls this once, and the label is peeling off in the warehouse seconds later. Status flows back through queued → dispatched → printing → completed, failures carry a reason code (PRINTER_OFFLINE, PRINT_ERROR, …), and webhooks can notify your system the moment a label fails so the order doesn't ship without one.
One more honest note: if your carrier hands you finished PDF labels instead of raw data, don't reconstruct them in ZPL — send the PDF as-is with content_type: "pdf" to the same endpoint. ZPL shines when you generate the label.
Field-tested gotchas
- Everything prints small (or huge): DPI mismatch. Check the printhead's DPI and scale your dot coordinates.
- Blurry or unscannable barcodes: never rasterize barcodes; use
^BC/^BQso the printer renders exact module widths. Verify with a phone scanner before going live. - Label drifts over a batch: run the printer's media calibration so it re-learns the gap between labels.
- Special characters print wrong: add
^CI28for UTF-8. CJK text additionally needs a font with those glyphs installed on the printer.
Ready to wire your warehouse up? Start free — 50 jobs a month, no card — or read the API docs.
