How to Make Packing Slips from Google Sheets Orders (One PDF per Batch, 2026)

If you ship orders yourself, the orders probably end up in a spreadsheet at some point: pasted from a store export, typed in from a marketplace, or collected through a form. Packing slips are the boring part. You want one page per order with the ship-to address and the items to pack, and you want to print today's batch in one go instead of opening twenty files. This guide shows how to do that from Google Sheets with a free Apps Script.

It differs from the purchase orders guide in two ways. The orders sheet has one row per item, which is how many store exports look, and every new order goes into a single Doc and PDF with a page per order.

Watch the 2-minute walkthrough on YouTube. Short on time? Watch the 50-second version.

Step 1: an Orders tab with one row per item

Create a tab named Orders. Each row is one item in an order, and rows that share an Order # belong to the same order. Useful columns: Order #, Order date, Ship to name, Ship to address, Shipping method, Gift note, Item, SKU, Qty.

Order #Order dateShip to nameShip to addressItemSKUQty
#1001Nov 11, 2026Ana Ruiz1 Elm St, SpringfieldCeramic mugMUG-012
#1001Coaster setCST-041
#1002Nov 11, 2026Ben Ode22 Pine Rd, RivertonTote bagTOTE1

Order details such as the address only need to be on one of an order's rows; the script takes the first non-empty value it finds for each column. That matters because some exports fill the order details only on the first item row and leave them blank on the rest.

Pasting a store export. If your store or marketplace lets you download orders as a CSV, import it with File › Import into a new tab, then either rename its columns to match the placeholders in your template or rename the placeholders to match the export. Column names just need to match exactly, including spaces and capitals. Check your platform's export first, since some put each item on its own row and others squeeze all items into one cell.

Addresses on several lines. If the address is split into Address 1, City, State and ZIP columns, either put each one in the template as its own placeholder or join them in a helper column, for example =TEXTJOIN(", ",TRUE,D2:G2).

Step 2: a Google Docs packing slip template

Create a Doc with your logo, shop name and return address, then add placeholders that match your Orders headers. Put {{Items}} alone on its own line where the item table should go; the script replaces that line with a table of Item, SKU and Qty. Packing slips usually leave prices out, so the example does too.

PACKING SLIP                      Order {{Order #}}
Order date: {{Order date}}         Ship via: {{Shipping method}}

Ship to:
{{Ship to name}}
{{Ship to address}}

{{Items}}

{{Gift note}}

Thanks for your order! Questions or returns: hello@yourshop.example

Keep at least one line of text after {{Items}}, keep it in the main body (not in a table, header or footer), and keep the slip short enough to fit on one page. Put your logo in as an inline image (Insert › Image, then choose In line in the image options) so it copies cleanly. Copy the template ID (between /d/ and /edit in its URL) and the ID of a Drive folder for the slips (after /folders/).

Step 3: make one printable PDF for all new orders

In the sheet, open Extensions › Apps Script, paste the script below, fill in the two IDs, save, then reload the sheet. A Packing slips menu appears; choose Make slips for new orders and approve the permissions the first time.

The script groups the rows by Order #, fills a copy of the template for each order, and puts them all into one Doc named like Packing slips 2026-11-11 1430, with a page break between orders. It saves that Doc as a PDF in the same folder and records each order on a Slips tab, which it creates on the first run. Orders already on the Slips tab are skipped, so next time you only get the new ones. Placeholders whose column is empty for an order (say, no gift note) are left blank.

/**
 * Packing slips from Google Sheets.
 * Tab "Orders": one row per line item (Order #, Order date, Ship to name,
 *               Ship to address, Shipping method, Item, SKU, Qty, ...).
 *               Rows with the same Order # are one order; order details
 *               only need to be filled on one of its rows.
 * Tab "Slips":  created automatically; lists every order that already has a slip.
 * Each run puts every new order's slip into ONE Google Doc (a page per order)
 * and saves it as one PDF, so a whole batch prints in one go.
 */
const TEMPLATE_ID  = 'PASTE_TEMPLATE_DOC_ID';
const FOLDER_ID    = 'PASTE_OUTPUT_FOLDER_ID';
const ITEM_HEADERS = ['Item', 'SKU', 'Qty'];

function onOpen() {
  SpreadsheetApp.getUi().createMenu('Packing slips')
    .addItem('Make slips for new orders', 'makePackingSlips')
    .addToUi();
}

function makePackingSlips() {
  const ss   = SpreadsheetApp.getActive();
  const data = ss.getSheetByName('Orders').getDataRange().getDisplayValues();
  const head = data[0];
  const orderCol = head.indexOf('Order #');
  const itemCols = ITEM_HEADERS.map((h) => head.indexOf(h));

  const log = ss.getSheetByName('Slips') || ss.insertSheet('Slips');
  if (log.getLastRow() === 0) log.appendRow(['Order #', 'Batch', 'PDF', 'Created']);
  const done = new Set(log.getDataRange().getDisplayValues().slice(1)
    .map((r) => String(r[0]).trim()));

  // Group line-item rows by Order #, skipping orders that already have a slip.
  const orders = new Map();
  for (let r = 1; r < data.length; r++) {
    const id = String(data[r][orderCol] || '').trim();
    if (!id || done.has(id)) continue;
    if (!orders.has(id)) orders.set(id, { fields: {}, items: [] });
    const o = orders.get(id);
    head.forEach((h, i) => { if (h && !o.fields[h] && data[r][i]) o.fields[h] = data[r][i]; });
    o.items.push(itemCols.map((c) => (c === -1 ? '' : data[r][c])));
  }
  if (orders.size === 0) {
    SpreadsheetApp.getUi().alert('No new orders without a packing slip.');
    return;
  }

  const folder = DriveApp.getFolderById(FOLDER_ID);
  const stamp  = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'yyyy-MM-dd HHmm');
  const name   = 'Packing slips ' + stamp;
  // The batch starts as a copy of the template so margins, header and footer carry over.
  const batchFile = DriveApp.getFileById(TEMPLATE_ID).makeCopy(name, folder);
  const batch = DocumentApp.openById(batchFile.getId());
  const out   = batch.getBody();
  out.clear();

  let first = true;
  orders.forEach((o, id) => {
    const tmpFile = DriveApp.getFileById(TEMPLATE_ID).makeCopy('tmp ' + id, folder);
    const doc  = DocumentApp.openById(tmpFile.getId());
    const body = doc.getBody();
    insertItemTable_(body, [ITEM_HEADERS].concat(o.items));
    Object.keys(o.fields).forEach((h) => {
      const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
      body.replaceText('\\{\\{' + escaped + '\\}\\}', o.fields[h]);
    });
    body.replaceText('\\{\\{[^}]*\\}\\}', '');            // blank any field left empty
    if (!first) out.appendPageBreak();
    first = false;
    appendBody_(out, body);
    doc.saveAndClose();
    tmpFile.setTrashed(true);
  });

  batch.saveAndClose();
  const pdf = folder.createFile(batchFile.getAs(MimeType.PDF).setName(name + '.pdf'));
  orders.forEach((o, id) => log.appendRow([id, name, pdf.getUrl(), new Date()]));
}

// Replaces the paragraph that contains {{Items}} with a table of the order's items.
function insertItemTable_(body, cells) {
  const found = body.findText('\\{\\{Items\\}\\}');
  if (!found) return;
  const para  = found.getElement().getParent();
  const table = body.insertTable(body.getChildIndex(para), cells);
  table.getRow(0).editAsText().setBold(true);
  para.removeFromParent();
}

// Copies paragraphs, tables and list items from one Doc body to the end of another.
function appendBody_(out, body) {
  const T = DocumentApp.ElementType;
  for (let i = 0; i < body.getNumChildren(); i++) {
    const el = body.getChild(i).copy();
    const t  = el.getType();
    if (t === T.PARAGRAPH) out.appendParagraph(el);
    else if (t === T.TABLE) out.appendTable(el);
    else if (t === T.LIST_ITEM) out.appendListItem(el);
  }
}

Open the PDF and print it once for the whole batch. If you need to reprint a single order, delete its row on the Slips tab and run the menu again; it will get its own small batch. To add a column to the item table, such as Size or Bin location, add the header to ITEM_HEADERS and a matching column on the Orders tab.

Tips

Want documents without maintaining a script? SheetWeave (disclosure: we make it) is a Google Sheets add-on that fills a Google Docs template from each row and saves a Doc, a PDF, or both in a Drive folder you choose, writing the links back to the row. It makes one document per row, so it fits orders kept one row per order (with the items in that row's columns); for one row per item and a single batch PDF, use the script above. Sheet data stays inside your Google account and it uses narrow scopes. 14-day free trial, no credit card required.
See SheetWeave

Related Google Sheets guides

Instructions refer to Google Sheets, Google Docs, Google Forms, Google Drive and Apps Script as they worked on November 11, 2026. Not affiliated with Google or any other vendor named here. Google Sheets, Google Docs, Google Forms, Google Slides and Google Drive are trademarks of Google LLC. Features change, so verify in your own account. Questions or corrections: hello@loomwise.app.