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 date | Ship to name | Ship to address | Item | SKU | Qty |
|---|---|---|---|---|---|---|
| #1001 | Nov 11, 2026 | Ana Ruiz | 1 Elm St, Springfield | Ceramic mug | MUG-01 | 2 |
| #1001 | Coaster set | CST-04 | 1 | |||
| #1002 | Nov 11, 2026 | Ben Ode | 22 Pine Rd, Riverton | Tote bag | TOTE | 1 |
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
- Only ship what's ready. If some orders are waiting on stock, keep them in a separate tab until they're ready, or add a filter step so only rows marked Ready are copied into Orders.
- Pick list first. For a bigger batch, a pick list saves walking back and forth. A pivot table on the Orders tab (Insert › Pivot table, rows SKU and values SUM of Qty) shows the total of each item to pull.
- Large backlogs. Apps Script runs stop after six minutes. The Slips tab is only updated at the end of a run, so if a very large first batch times out, trash the half-built Doc and run it on fewer orders at a time.
- Temporary copies. The script makes a working copy of the template for each order and moves it to the trash once its page is added to the batch. They empty out of the trash on Drive's normal schedule.
- Overdue shipments. An order that hasn't shipped a few days after its date is easy to lose. See automatic email reminders from a date column to get a nudge for rows that still aren't marked shipped.
- Receipts and invoices. The same sheet can produce customer paperwork too; see receipts from Google Sheets rows and invoices from rows.
See SheetWeave
Related Google Sheets guides
- How to generate a PDF or Google Doc for each row in Google Sheets
- Best Autocrat alternatives for mail merge to PDF and Docs
- Make invoices or certificates from Google Sheets rows
- Create sales quotes or estimates from Google Sheets
- Generate receipts from Google Sheets rows
- Create purchase orders from Google Sheets
- Generate a PDF certificate from a Google Form response
- Issue numbered training completion certificates with expiry dates
- Make a PDF progress report for each student
- Generate offer letters from a Google Sheets hiring tracker
- Email each row’s PDF as an attachment
- Automatic email reminders from a date column
- Contract renewal and expiration alerts from Google Sheets
- Track certificate of insurance (COI) expirations in Google Sheets
- Track employee license and certification expirations
- Track equipment and vehicle maintenance with reminders
- Track warranty expiration dates with reminders
- Birthday and work anniversary reminders from Google Sheets
- Rent due and lease renewal reminders from Google Sheets
- Send invoice payment reminders from Google Sheets
- Highlight overdue tasks and send email reminders
- Follow-up reminders for leads, clients and candidates
- Membership renewal reminders for clubs and associations
- SheetWeave vs Autocrat
- SheetWeave vs Document Studio
- SheetWeave vs RowDocs
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.