← All field notes
ZPL

ZPL in 20 minutes, a field guide for developers

ZPL is a small language you can learn in an afternoon. Most label bugs are not language bugs. They are DPI and origin arithmetic. Here is the anatomy, a complete 4x6 shipping label, and the bugs you will hit first.

ZPL is the Zebra Programming Language, a plain-text command language that thermal label printers execute directly. A label is a list of fields, each placed at an x,y position measured in printer dots. That is the whole model. Almost every bug you will hit in your first week is a number that assumed the wrong dots-per-inch or the wrong origin, not a command you misspelled.

Command syntax below is checked against Zebra's ZPL II Programming Guide, P1134473-11EN Rev A, dated 2026-07-05, and the complete label is rendered through Labelary. Many non-Zebra thermal printers also speak ZPL with small dialect differences, so your own printer is the final test.

A ZPL label is a list of fields between ^XA and ^XZ

Format commands start with a caret and control commands with a tilde, which matters when you are filtering ZPL rather than writing it. ^XA opens a label format and ^XZ closes it. Between them, each printable thing is a field: an origin, a font or barcode setting, the data, and a field separator.

^XA
^FO50,50^A0N,42,42^FDHELLO^FS
^XZ

^FO50,50 sets the field origin 50 dots right and 50 dots down from the label home. ^A0N,42,42 selects font 0, the built-in scalable font, normal orientation, 42 dots tall and wide. ^FDHELLO is the field data. ^FS ends the field, and forgetting one is a classic cause of a blank label.

The commands you will actually use fit in one table.

Command What it does Notes from the guide
^XA / ^XZ Start and end a label format One label per pair; ^PQ prints copies
^FOx,y Field origin in dots 0 to 32000 each; relative to ^LH
^LHx,y Label home, shifts every later ^FO Put it before the first ^FS
^Afo,h,w Font, orientation (N, R, I, B), height, width in dots Scalable fonts 10 to 32000 dots
^FD...^FS Field data and field separator ^FS can also be sent as hex 0F
^GBw,h,t,c,r Box or line: width, height, thickness, color, rounding Set h to the thickness for a horizontal rule
^BYw,r,h Barcode module width (1 to 10 dots, power-up 2), ratio (2.0 to 3.0, no effect on fixed-ratio symbologies), default height (power-up 10) Persists for every later barcode in the format
^BCo,h,f,g,e,m Code 128: orientation, height, interpretation line, line above, UCC check digit, mode f=Y prints the human-readable line (the default); m is the mode, and it is not o
^B3o,e,h,f,g Code 39 e=Y adds a Mod-43 check digit
^BQa,b,c,d,e QR code: orientation, model (2 recommended), magnification, error correction, mask Magnification 1 to 100; field data needs a switch prefix, see below
^PWa Print width in dots Clipped to the head width
^LLy Label length in dots Needed for continuous media
^PQq Quantity 1 to 99,999,999
^CIa Character set; ^CI28 is UTF-8 Send it before any non-ASCII field data
^MDa Media darkness, -30 to 30, relative to the configured value Not absolute, and not cumulative; see below
^PRp Print rate in inches per second 2 through 12 or more, model dependent
^JUS Save current settings across power off Configuration, not label content

That is most of the language a developer needs. The rest of the guide, which runs to nearly 2,000 pages, is fonts, graphics, RFID, networking, and printer configuration.

Every number is in dots, so learn your printer's DPI first

ZPL has no inches or millimeters. Positions, sizes, and label dimensions are all in dots, and the dot size depends on the printhead. The guide's ^LL section gives the multipliers.

Printhead Marketing name Exact multiplier 4x6 label in dots
6 dots/mm 152 dpi 152.4 610 x 914
8 dots/mm 203 dpi 203.2 813 x 1219
12 dots/mm 300 dpi 304.8 1219 x 1829
24 dots/mm 600 dpi 609.6 2438 x 3658

In practice everyone rounds to the marketing dpi, so the 4x6 label on a 203 dpi printer is quoted as 812 by 1218 dots, not 813 by 1219. That is the pair to memorize, it is what Labelary renders, and it is what the example below declares. The one-dot difference never matters; the 50 percent difference between 203 and 300 does.

A label written for 203 dpi and sent to a 300 dpi printer prints at two thirds scale in the top-left corner. The reverse overflows the right and bottom edges. Neither produces an error. Keep one template per DPI and never mix them; if you must support both, multiply every coordinate, including the ^BY module width, by a scale factor at render time.

A complete 4x6 shipping label

This label renders on a 203 dpi printer. It has a sender block, a recipient block, an order line, a Code 128 tracking barcode with the human-readable line, and a QR code. Every coordinate is inside 812 x 1218.

^XA
^CI28
^PW812
^LL1218
^LH0,0
^FO30,30^GB752,1158,3^FS
^FO50,50^A0N,28,28^FDFROM^FS
^FO50,85^A0N,28,28^FDAcme Widgets^FS
^FO50,120^A0N,28,28^FD100 Industrial Way^FS
^FO50,155^A0N,28,28^FDDenver, CO 80202^FS
^FO50,215^GB712,3,3^FS
^FO50,245^A0N,32,32^FDSHIP TO^FS
^FO50,295^A0N,48,48^FDJane Doe^FS
^FO50,355^A0N,40,40^FD42 Example Street^FS
^FO50,405^A0N,40,40^FDApt 7^FS
^FO50,455^A0N,40,40^FDPortland, OR 97201^FS
^FO50,535^GB712,3,3^FS
^FO50,565^A0N,28,28^FDOrder 1042   1.2 kg   GROUND^FS
^FO50,620^A0N,28,28^FDTRACKING NUMBER^FS
^BY3,3,160
^FO50,660^BCN,160,Y,N,N^FD1Z999AA10123456784^FS
^FO50,900^GB712,3,3^FS
^BY2,3,10
^FO50,945^A0N,28,28^FDScan for tracking status^FS
^FO560,930^BQN,2,6^FDQA,https://example.com/t/1Z999AA10123456784^FS
^PQ1
^XZ

Things worth noticing:

  • ^CI28 comes first so a name with an accent encodes as UTF-8 instead of Zebra Code Page 850. Without it, é prints as two wrong characters.
  • ^PW812 and ^LL1218 pin the label size. Gap-sensed media usually tells the printer the length already, but stating it makes the file self-describing and protects you from a printer last calibrated for a different roll.
  • ^GB752,1158,3 draws the border 30 dots in from every edge. Printheads lose a few dots at the edges, so keep content 20 to 30 dots in.
  • Text rows step by 35 to 60 dots. A 40-dot font needs about 50 dots of line pitch.
  • The leading N in ^BCN,160,Y,N,N is orientation, not mode, which is a distinction worth holding on to.
  • ^BY2,3,10 before the QR is not decoration. ^BY's height parameter offsets ^BQ vertically, so without the reset the leftover 160 pushes the QR off the bottom edge.
  • ^BQN,2,6 is a model 2 QR at magnification 6. The field data starts with QA,: Q is the error correction level, A, selects automatic data input. The guide marks both switches mandatory, and leaving them out is the most common QR bug.

POSTed to the Labelary API at 8 dots/mm and 4x6, this file returns HTTP 200 and an 812 x 1218 PNG whose ink runs from (30, 30) to (781, 1187), inside the ^GB border. That is how we checked it, and the ^BY reset is in the file because the first version failed that check.

^BY sets the module, the barcode command sets the rest

Barcodes are where scale mistakes hurt most, because a barcode 5 percent off is a barcode the scanner rejects.

^BYw,r,h sets the narrow bar width in dots (1 to 10, power-up 2), the wide-to-narrow ratio (2.0 to 3.0, default 3.0, no effect on fixed-ratio symbologies like Code 128), and a default height. It applies to every barcode after it. A 2-dot module at 203 dpi is 0.25 mm, thin for a handheld scanner on cheap direct thermal stock. Use 3 for tracking numbers unless the label is too narrow.

Code 128 with ^BC suits tracking numbers and most mixed alphanumeric data. The Mod 103 check digit is always present and cannot be turned off; e=Y adds the extra UCC Mod 10 digit only when a spec demands it.

One parameter of ^BC invites a mistake. The first is orientation, not mode. Mode is the sixth, it defaults to N, and N means "no selected mode", which is no automatic packing at all. Automatic subset selection is mode A, which shifts to subset C for a run of four or more digits and is what makes numeric-heavy data print compact. If you want it, ask for it: ^BCN,160,Y,N,N,A.

Code 39 with ^B3 is older and wider. Use it only when a downstream system requires it. Its optional Mod-43 check digit is e=Y.

QR codes with ^BQ default to model 2. Magnification runs 1 to 100 and its default comes from the printhead: 1 at 150 dpi, 2 at 200, 3 at 300, 6 at 600. Magnification 2 on a 203 dpi head is small; 5 or 6 is comfortable for a phone at arm's length. Pass the error correction level in the ^FD switch rather than the d parameter: the guide contradicts itself about the default, calling Q the value for an empty d and M the standard level, so state it and move on. Use HA, instead of QA, when the label may be scuffed.

^BY has one more effect on ^BQ that is not in the guide. A ^BY height set earlier in the format pushes the QR down from its ^FO origin by that many dots, without changing its size. That is why the example resets ^BY2,3,10: the leftover ^BY3,3,160 from the tracking barcode moves the QR 150 dots lower than the coordinate says, off the bottom of the label. It reproduces in Labelary, and assume your firmware does the same. Reset ^BY before a ^BQ, or place the QR first.

Printer settings belong in configuration, not in every label

^MD, ^PR, and ^JUS change the printer, not the label. They are tempting to paste into every job and they cause the strangest bugs.

^MDa is media darkness from -30 to 30, relative to the value already configured on the printer. The guide's examples: from a configured 16, ^MD-9 gives 7; from 1, ^MD15 gives 16; from 25, ^MD10 gives 30, the maximum. So ^MD15 in every label does not set darkness to 15, and on a printer configured at 25 it clips and you get black smears.

It is relative but not cumulative: each ^MD is applied against the value on the configuration label, so 15 then ^MD-6 is 9, and a later ^MD2 is 17, not 11. That is worse than drift, because the darkness you get depends on a printer setting nobody on your team chose. Set darkness once during setup, follow it with ^JUS, and keep it out of production labels.

^PRp sets print speed in inches per second (A or 2, B or 3, C or 4, up to 12 or more depending on model). Faster is lighter. If barcodes fade at high speed, slow down before you raise darkness.

^JUS saves the current configuration so it survives a power cycle. It belongs in a setup script, never in a label sent thousands of times a day.

The bugs you will actually hit, in order

Symptom Likely cause Fix
Label prints tiny in the top-left corner Template for 203 dpi, printer is 300 dpi Scale every coordinate and the ^BY module by 1.5, or keep a per-DPI template
Content runs off the right or bottom The reverse mismatch, or ^PW/^LL larger than the media Check head density, then ^PW and ^LL against the label
Everything shifted by a fixed amount A stale ^LH, or an offset stored on the printer Send ^LH0,0 at the top; check the printer's configured top and left position
Blank label, or fields missing after one point A missing ^FS swallowed the rest Terminate every field; the culprit is usually the field before the first missing one
Barcode will not scan Module too small or quiet zone violated Module 3 at 203 dpi and clear white space each side
Barcode too wide for the label Data plus a 3-dot module exceeds the width Drop to module 2 or rotate with ^BCR
One label prints across two, or a blank one follows Label length mismatch or media not calibrated Set ^LL to the real length; run media calibration once
Accented characters print as garbage UTF-8 data without ^CI28 Add ^CI28 before the first field
QR prints nothing Missing QA, prefix in the ^FD ^FDQA, then your data
QR sits lower than its ^FO says, or falls off the label A ^BY height left over from an earlier barcode offsets ^BQ Reset ^BY before the QR, or move the QR above the barcode
Darkness is wrong on one printer and right on another ^MD in a label, applied against each printer's own configured value Remove it from labels, set once, ^JUS
Printer spits out a page of text ZPL sent through a driver that rasterized it, or a PDF sent down a raw queue Match content type to path; ZPL goes raw

The last row costs the most time. ZPL through a Windows driver that expects documents prints a picture of the text ^XA^FO.... A PDF sent to a raw ZPL queue prints the PDF's bytes as garbage. Choosing between the two is the subject of ZPL vs PDF for shipping labels.

Render it in an emulator before you burn a roll

Labelary hosts a ZPL emulator with a web viewer and an HTTP API. The API takes the density in dots per millimeter (6, 8, 12, or 24), the label size in inches, and a zero-based label index, and returns a PNG by default or a PDF with Accept: application/pdf. POST the ZPL as the body.

curl --request POST \
  --data-binary @label.zpl \
  --header "Accept: image/png" \
  --output label.png \
  http://api.labelary.com/v1/printers/8dpmm/labels/4x6/0/

For the label above this returns HTTP 200 and an 812 x 1218 PNG. Put it in CI: render every template at every DPI you support and diff against a stored image. A template that silently moved 30 dots is a failed test, not a warehouse call.

Two caveats from Labelary's service page: the free tier is rate limited to 3 requests per second and 5,000 per day, and an emulator is not your printer. Font metrics, darkness, and media handling differ. Render to find layout bugs, then print a short run on the real hardware.

Sending ZPL from a backend is base64 and a POST

The bytes still need to reach a raw queue on a computer next to the printer, with no driver rendering in between.

RocketPrint is a remote printing API and desktop Station for software platforms: your backend POSTs a print job, and a small app beside the printer prints it, with no inbound networking, no drivers for raw label formats, and a durable job record. For ZPL the content type is raw_base64: encode the exact bytes and send them.

# tr strips the line breaks GNU base64 adds; macOS base64 emits none
CONTENT="$(base64 < label.zpl | tr -d '\n')"

curl -X POST https://rocketprint.io/api/v1/print-jobs \
  -H "X-API-Key: $ROCKETPRINT_KEY" \
  -H "Idempotency-Key: order-1042-shipping-label" \
  -H "Content-Type: application/json" \
  -d "{
    \"printerId\": \"YOUR_PRINTER_ID\",
    \"contentType\": \"raw_base64\",
    \"content\": \"$CONTENT\",
    \"options\": { \"copies\": 1 }
  }"

Station passes those bytes to the OS raw path unchanged (CUPS with the raw option on macOS, a native Winspool helper on Windows). It does not parse, validate, or scale ZPL, so a template with the wrong DPI prints with the wrong DPI. For raw jobs the only option the API honors is copies (1 to 100); paper size, duplex, and color apply to documents, because a label defines its own size. Use ^PQ when you want the printer to count copies, or copies when you want the job record to show it, not both.

One limit worth stating plainly: completed means the print command succeeded through the OS print path. It does not prove a label emerged, and it cannot tell you the barcode scans. The job status reference covers what each state does and does not prove, and the API reference lists the fields above.

To see the whole loop, from API key to a label you can hold, the quickstart does it in two curl commands.

END / zpl-field-guide-for-developers

More field notes