Exporting .xlsx files with multiple worksheets

Matty3
Tera Contributor

My current requirement is to create a UI Action that queries a table and attaches the results to a form as an .xlsx attachment. I've seen a few suggestions on the forum for creating a spreadsheet from a query, but I couldn't find any questions about multiple worksheets. So, I guess I'm really asking two separate questions.

 

Is there a recommended way to create an .xlsx from a query?

Is it possible to create an .xlsx with two or more worksheets?

 

Thank you!

6 REPLIES 6

Matty3
Tera Contributor

Can you provide a reference to the Excel Export API? I was unable to find it in ServiceNow or online. Thank you!

Craig Talbert4
Tera Contributor

The sn_export_data.ExcelExport API referenced above doesn't exist. I tested on Yokohama and the constructor is not defined. The sample code appears to be AI-generated.


Here's an approach that actually works. A .xlsx file is just a ZIP archive of XML files (ECMA-376 / Office Open XML). The Java ZIP classes are whitelisted and accessible in global scope:

  • java.util.zip.ZipOutputStream
  • java.util.zip.ZipEntry
  • java.io.ByteArrayOutputStream

You build six XML strings (content types, relationships, workbook, workbook rels, styles, worksheet), zip them, and attach the byte[] via GlideSysAttachment.write(). The output is a real .xlsx that Excel opens with zero format warnings. No more having to worry about "file format and extension don't match" dialogs.


Here's the core pattern (stripped to essentials):

 
var ZipOutputStream = Packages.java.util.zip.ZipOutputStream;
var ZipEntry = Packages.java.util.zip.ZipEntry;
var ByteArrayOutputStream = Packages.java.io.ByteArrayOutputStream;

var baos = new ByteArrayOutputStream();
var zip = new ZipOutputStream(baos);

function addEntry(zip, path, content) {
    zip.putNextEntry(new ZipEntry(path));
    var bytes = new java.lang.String(content).getBytes('UTF-8');
    zip.write(bytes, 0, bytes.length);
    zip.closeEntry();
}

// 1. [Content_Types].xml -- package manifest
addEntry(zip, '[Content_Types].xml',
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
    '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
    '<Default Extension="xml" ContentType="application/xml"/>' +
    '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' +
    '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' +
    '<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' +
    '</Types>');

// 2. _rels/.rels
addEntry(zip, '_rels/.rels',
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' +
    '</Relationships>');

// 3. xl/workbook.xml
addEntry(zip, 'xl/workbook.xml',
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
    '<sheets><sheet name="Sheet1" sheetId="1" r:id="rId1"/></sheets></workbook>');

// 4. xl/_rels/workbook.xml.rels
addEntry(zip, 'xl/_rels/workbook.xml.rels',
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
    '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>' +
    '<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' +
    '</Relationships>');

// 5. xl/styles.xml (font 0 = normal, font 1 = bold, cellXf 1 = bold)
addEntry(zip, 'xl/styles.xml',
    '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
    '<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font>' +
    '<font><b/><sz val="11"/><name val="Calibri"/></font></fonts>' +
    '<fills count="2"><fill><patternFill patternType="none"/></fill>' +
    '<fill><patternFill patternType="gray125"/></fill></fills>' +
    '<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>' +
    '<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' +
    '<cellXfs count="2"><xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' +
    '<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/></cellXfs>' +
    '</styleSheet>');

// 6. xl/worksheets/sheet1.xml -- your data goes here
var ws = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
    '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' +
    '<cols><col min="1" max="1" width="15"/><col min="2" max="2" width="40"/>' +
    '<col min="3" max="3" width="12"/></cols><sheetData>';

// Bold header row (s="1" = cellXf index 1)
ws += '<row r="1">';
ws += '<c r="A1" s="1" t="inlineStr"><is><t>Number</t></is></c>';
ws += '<c r="B1" s="1" t="inlineStr"><is><t>Short Description</t></is></c>';
ws += '<c r="C1" s="1" t="inlineStr"><is><t>Priority</t></is></c>';
ws += '</row>';

// Data rows
var gr = new GlideRecord('incident');
gr.addEncodedQuery('active=true');
gr.setLimit(100); // limit for demo
gr.query();
var r = 2;
while (gr.next()) {
    ws += '<row r="' + r + '">';
    ws += '<c r="A' + r + '" t="inlineStr"><is><t>' + xmlEsc(gr.getValue('number')) + '</t></is></c>';
    ws += '<c r="B' + r + '" t="inlineStr"><is><t>' + xmlEsc(gr.getDisplayValue('short_description')) + '</t></is></c>';
    ws += '<c r="C' + r + '" t="inlineStr"><is><t>' + xmlEsc(gr.getDisplayValue('priority')) + '</t></is></c>';
    ws += '</row>';
    r++;
}
ws += '</sheetData></worksheet>';
addEntry(zip, 'xl/worksheets/sheet1.xml', ws);
zip.close();

// Attach to any GlideRecord -- use the byte[] overload, NOT writeContentStream
var sa = new GlideSysAttachment();
sa.write(targetGr, 'report.xlsx',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    baos.toByteArray());

function xmlEsc(s) {
    if (!s) return '';
    return s.toString().replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}​


Key things to know:

  • Use GlideSysAttachment.write(GlideRecord, String, String, byte[]) -- the byte[] overload. writeContentStream() expects GlideScriptableInputStream and won't work with ByteArrayInputStream.
  • All six ZIP entries are required. If [Content_Types].xml is missing an Override for any part, Excel opens a blank file with no error.
  • t="inlineStr" on every cell prevents formula injection (=, +, -, @).
  • Multi-tab: add more Override entries in content types, more sheet entries in workbook.xml, more relationships, and more sheetN.xml files.
  • Same ~50K row memory ceiling as SpreadsheetML, but output is ~10x smaller thanks to ZIP compression.

I wrote a full runnable background script (with email sending via staged insertion) as a Developer Article here: https://www.servicenow.com/community/developer-forum/generate-real-xlsx-files-in-servicenow-without-...


Tested on Yokohama. 4,300 rows, 222KB output, zero warnings.