← All field notes
ESC/POS

ESC/POS for receipt printers, explained for web developers

ESC/POS is a stream of bytes, not a document format. Six commands cover most receipts, code pages replace UTF-8, and anything fancy gets rendered to a bitmap. With a complete Node example.

ESC/POS is not a document format. It is a byte stream that a receipt printer executes as it arrives: some bytes are text, and some are commands that change alignment, size, or emphasis, or tell the printer to cut. If you have only ever produced HTML and PDFs, the mental model to replace is "render a page." The correct model is "write to a serial port that happens to print."

That model is why receipt printing is easier than it looks. You need about six commands for a typical receipt, a code page instead of UTF-8, and one image command for everything else.

ESC/POS is Epson's command set and the de facto standard for receipt printers

ESC/POS was defined by Epson for its TM series of point-of-sale printers, and the ESC/POS command reference is the primary source. Nearly every thermal receipt printer sold since, including the inexpensive 58 mm units in Hacker News projects, implements a subset of it.

Commands begin with a control byte. ESC is 0x1B, GS is 0x1D, and the byte after it names the command. Parameters follow as raw bytes, not ASCII digits, which is the first trap: to select center alignment you send the byte 0x01, not the character 1. Several commands accept either form (ESC a takes 0 to 2 or 48 to 50, GS V takes 0 or 48 for a full cut), which is why you see both in example code. Check the command's own page before assuming it.

Six commands cover most receipts

Purpose Command Bytes (hex) Notes
Initialize ESC @ 1B 40 Resets modes. Send it first, every time
Line feed LF 0A Prints the buffered line and advances
Justify ESC a n 1B 61 n n = 0 left, 1 center, 2 right
Emphasis (bold) ESC E n 1B 45 n n = 1 on, 0 off
Character size GS ! n 1D 21 n High nibble width, low nibble height, each 0 to 7 meaning 1x to 8x
Cut GS V m or GS V m n 1D 56 m / 1D 56 m n m = 0 full, 1 partial; m = 65 or 66 feeds n units first, then cuts

Two more show up in commerce. ESC d n (1B 64 n) prints and feeds n lines, used before the cut so the last line clears the blade. ESC p m t1 t2 (1B 70 m t1 t2) fires the cash drawer solenoid on connector pin m (0 or 1) for t1 times 2 ms on and t2 times 2 ms off; 1B 70 00 32 32 is 100 ms on and 100 ms off on pin 2, which is python-escpos's CD_KICK_2 default.

Text is whatever bytes sit between commands, printed when the printer sees LF. There is no word wrap; you wrap yourself, based on the column count for the font and paper width.

A minimal receipt, byte for byte:

1B 40                 ESC @        initialize
1B 61 01              ESC a 1      center
1D 21 11              GS ! 0x11    double width, double height
1B 45 01              ESC E 1      bold on
41 43 4D 45 0A        "ACME\n"
1B 45 00              ESC E 0      bold off
1D 21 00              GS ! 0       normal size
1B 61 00              ESC a 0      left
54 6F 74 61 6C 20 31 33 2E 35 30 0A    "Total 13.50\n"
1B 64 03              ESC d 3      feed 3 lines
1D 56 42 00           GS V 66 0    feed to cut position, partial cut

The python-escpos constants mirror these values and are a convenient second source.

Text is a code page, so UTF-8 is not a thing here

ESC/POS printers do not understand UTF-8. Bytes above 0x7F are looked up in the currently selected character code table, chosen with ESC t n (1B 74 n). n = 0 is PC437 (the original IBM PC set), 16 is Windows-1252, and 19 is PC858 (PC850 plus the euro sign). The table numbering varies by vendor and model, which is the point: a receipt that renders "café" correctly on one printer prints "café" on the next.

Three ways to handle this, in increasing order of effort and correctness:

  1. Restrict to ASCII. Fine for a kitchen ticket, not for a customer's name.
  2. Select a code page and transcode. Send 1B 74 10 for Windows-1252 and encode each string to that page (in Node, iconv-lite does this). Anything outside the page becomes ?.
  3. Render text as an image. Correct for any script, at the cost of larger payloads and slower printing. This is what the Markdown-to-receipt projects on Hacker News settled on (see the ThermalMarky discussion).

Barcodes and QR codes are printer-native

You do not draw barcodes. The printer has them built in.

One-dimensional barcodes use GS k. The modern form (Epson's function B) is 1D 6B m n d1..dn, where m selects the symbology, n is the byte count of the data, and the data follows. m = 73 is Code 128, m = 69 is Code 39. For Code 128 the data starts with a code set selector, {B for code set B (mixed-case ASCII), so 1042 is sent as {B1042 with n = 6. Set height with GS h n (1D 68 n, in dots), module width with GS w n (1D 77 n, 2 to 6), and human-readable text position with GS H n (1D 48 n, 2 = below).

QR codes use the two-dimensional family GS ( k, with cn = 49 selecting QR. It is a sequence of five commands, each shaped 1D 28 6B pL pH cn fn [m] [data], where pL pH is the little-endian byte count of everything after it:

Step fn Payload Meaning
Model 65 50 00 QR model 2, then a reserved 0 byte
Module size 67 1 to 16 Dots per module
Error correction 69 48 L, 49 M, 50 Q, 51 H Recovery level
Store data 80 48 then the bytes Loads the symbol buffer
Print 81 48 Prints the stored symbol

This is the sequence python-escpos emits in its native qr() path (source). Not every low-cost printer implements GS ( k; if yours prints nothing, render the QR as an image instead.

Render everything else as a raster image

Logos, non-Latin text, and anything you would rather build in HTML: render to a 1-bit bitmap and send it with GS v 0.

The command is 1D 76 30 m xL xH yL yH d1..dk. m is the density (0 = normal). xL xH is the width in bytes, little-endian, with each byte carrying 8 horizontal pixels, most significant bit leftmost. yL yH is the height in dots. k is width bytes times height. A 384-dot-wide image is 48 bytes per row, so a 200-row logo is 9,600 bytes after the 8-byte header.

Epson lists GS v 0 as obsolete in favor of GS ( L, but it remains the most widely implemented image command on non-Epson hardware, and python-escpos still uses it as its bitImageRaster implementation.

The HTML route: render your template in headless Chromium at the paper's pixel width, convert to grayscale, dither to 1-bit, pack the bits, and emit GS v 0. That removes the code page problem entirely. The price is payload size (tens of kilobytes per receipt instead of hundreds of bytes) and speed, since the printer moves the head for every row rather than using its font ROM.

Paper width sets your pixel budget

Two widths cover the market. A 58 mm printer has roughly a 48 mm printable area at 8 dots per mm, or 384 dots. An 80 mm printer has roughly 72 mm printable, or 576 dots. Vendor specs quote exactly those figures, but read your printer's spec, because a few units differ.

In text mode, the default Font A is 12 dots wide on most printers, which gives 32 columns on 58 mm and 48 on 80 mm. Font B (9 dots) gives 42 and 64. Pad or truncate every line to that count.

A complete receipt in Node

The listing below builds a receipt as a Buffer using only the commands above. It runs under Node 18 or later with no dependencies, and we ran it before publishing (289 bytes, beginning with 1B 40).

const ESC = 0x1b;
const GS = 0x1d;

const parts = [];
const raw = (...bytes) => parts.push(Buffer.from(bytes));
const line = (s = "") => parts.push(Buffer.from(s + "\n", "ascii"));

const init = () => raw(ESC, 0x40);                       // ESC @
const align = (n) => raw(ESC, 0x61, n);                  // ESC a n: 0 left, 1 center, 2 right
const bold = (on) => raw(ESC, 0x45, on ? 1 : 0);         // ESC E n
const size = (w, h) => raw(GS, 0x21, ((w - 1) << 4) | (h - 1)); // GS ! n
const feed = (n) => raw(ESC, 0x64, n);                   // ESC d n
const cut = () => raw(GS, 0x56, 0x42, 0x00);             // GS V 66 0: feed to cut, partial cut
const drawer = () => raw(ESC, 0x70, 0x00, 0x32, 0x32);   // ESC p: pin 2, 100 ms pulse

// Code 128 (function B): GS k 73 n {B data
const code128 = (data) => {
  const payload = Buffer.from("{B" + data, "ascii");
  raw(GS, 0x68, 60);                // GS h: height 60 dots
  raw(GS, 0x77, 2);                 // GS w: module width 2
  raw(GS, 0x48, 2);                 // GS H: human-readable text below
  raw(GS, 0x6b, 73, payload.length);
  parts.push(payload);
};

// QR via GS ( k, cn = 49. pL pH = little-endian length of cn fn [m] data
const qr = (data) => {
  const gsk = (fn, body) => {
    const len = body.length + 2;
    raw(GS, 0x28, 0x6b, len & 0xff, (len >> 8) & 0xff, 49, fn);
    parts.push(body);
  };
  gsk(65, Buffer.from([50, 0]));                                // model 2 (n1 n2)
  gsk(67, Buffer.from([6]));                                    // module size 6
  gsk(69, Buffer.from([49]));                                   // error correction M
  gsk(80, Buffer.concat([Buffer.from([48]), Buffer.from(data, "ascii")])); // store
  gsk(81, Buffer.from([48]));                                   // print
};

init();
align(1);
size(2, 2);
bold(true);
line("ACME CAFE");
bold(false);
size(1, 1);
line("Order #1042");
line();
align(0);
line("Flat white           x2     9.00");
line("Almond croissant     x1     4.50");
line("--------------------------------");
bold(true);
line("TOTAL                      13.50");
bold(false);
line();
align(1);
qr("https://example.com/orders/1042");
line();
code128("1042");
feed(3);
cut();
drawer();

const receipt = Buffer.concat(parts);
console.log(receipt.length, receipt.subarray(0, 4).toString("hex")); // 289 1b401b61

The item lines are padded by hand to 32 columns for a 58 mm printer; in real code a row(left, right, width) helper does the arithmetic.

Test without wasting a roll of paper

  • Keep a hex dump of what you sent. When output is wrong, the bug is almost always a missing LF, or a parameter sent as an ASCII digit when the printer wanted a raw byte, or the reverse.
  • Send ESC @ first, always. Printers keep mode state between jobs, and the previous job's double-height setting will leak into yours.
  • Probe the printer's real subset before relying on QR or partial cut: print a numbered line, then one feature, and repeat. The last number on the paper tells you which command the printer ignored.

Sending the bytes from a web application

The browser cannot open a USB or serial port to a receipt printer in any portable way, and a hosted backend cannot reach a printer on a shop's LAN. A web application needs a way to hand the exact byte stream to a program beside the printer.

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 ESC/POS, the job body is the receipt buffer, base64-encoded, with contentType set to raw_base64. Below, rp is the Node client:

await rp.printJobs.create(
  { printerId, contentType: "raw_base64", content: receipt.toString("base64"), title: "Order 1042" },
  { idempotencyKey: "order-1042-receipt" },
);

The Station passes the bytes to the OS raw path (CUPS with the raw option on macOS, a native Winspool helper on Windows), so the printer receives exactly the buffer you built. RAW jobs accept copies as their only option; everything else is already in the bytes. The idempotency key means a retried request cannot fire the cash drawer twice (how idempotency prevents duplicate output), and a job for an offline printer waits until it expires or you cancel it (what happens when the printer goes offline).

One limit to state plainly: completed means the OS print system accepted the bytes, not that a receipt emerged. A printer with an open cover or an empty roll will accept the job and print nothing, and ESC/POS status polling (DLE EOT) needs a two-way connection the raw path does not give you. See the printers and formats page for what the raw path supports and the API reference for the request shape.

Receipts are the friendliest place to start with raw printing: the commands are few, the printers are cheap, and a bug costs a strip of paper.

END / escpos-field-guide-for-web-developers

More field notes →