Guide
Automating Reporting WorkflowsDeep dive

Convert Only Selected Sheets of a Workbook to PDF

Export the summary without the raw data — build a trimmed copy of the workbook, or use LibreOffice's export filter, and control the order sheets appear in the PDF.

A working workbook has more in it than the reader needs: a summary, a detail sheet, a lookup table, a raw extract nobody should see. Converting the whole thing to PDF publishes all of it, at forty pages instead of three. LibreOffice has no straightforward "just this sheet" flag, so the reliable approach is to shape a copy of the workbook first and convert that. This guide covers the trimmed-copy method, the hidden-sheet shortcut, and the chart-preservation trap that decides which one you can use. It extends Exporting Excel Reports to PDF.

Trim a copy, convert the copy, keep the original The source workbook holds five sheets: Summary, Regional, Detail, Lookups and an underscore-prefixed raw extract. A temporary copy is made and the three sheets not wanted in the document are deleted from it, leaving Summary and Regional in that order. LibreOffice converts the copy to a three-page PDF. The original workbook is never modified, and the temporary copy is discarded afterwards. source workbook Summary Regional Detail Lookups _raw never modified copy temporary copy Summary Regional the other three deleted order controlled here soffice report.pdf 3 pages, not 40 no lookups, no raw extract

Prerequisites

Bash
pip install pandas openpyxl

Plus LibreOffice installed, as in converting an Excel file to PDF.

A workbook with more sheets than the reader needs:

Python
import pandas as pd

summary = pd.DataFrame({"region": ["North", "South"], "revenue": [5150.0, 4268.5]})
regional = pd.DataFrame({"branch": [f"Branch {i}" for i in range(1, 21)],
                         "revenue": [100.0 * i for i in range(1, 21)]})
detail = pd.DataFrame({"order": range(1, 501), "amount": range(1, 501)})
lookups = pd.DataFrame({"code": ["N", "S"], "region": ["North", "South"]})

with pd.ExcelWriter("workbook.xlsx", engine="xlsxwriter") as writer:
    summary.to_excel(writer, sheet_name="Summary", index=False)
    regional.to_excel(writer, sheet_name="Regional", index=False)
    detail.to_excel(writer, sheet_name="Detail", index=False)
    lookups.to_excel(writer, sheet_name="Lookups", index=False)
    detail.to_excel(writer, sheet_name="_raw", index=False)

Step 1 — Build a trimmed copy

Copy the file, delete the unwanted sheets from the copy, convert that. The original is never touched:

Python
import shutil
import tempfile
from pathlib import Path
from openpyxl import load_workbook

def trimmed_copy(src, keep, dest=None):
    """A copy of the workbook containing only `keep`, in that order."""
    src = Path(src)
    dest = Path(dest) if dest else Path(tempfile.mkdtemp()) / src.name
    dest.parent.mkdir(parents=True, exist_ok=True)
    shutil.copyfile(src, dest)

    wb = load_workbook(dest)

    missing = [name for name in keep if name not in wb.sheetnames]
    if missing:
        raise KeyError(f"sheets not in {src.name}: {', '.join(missing)}")

    for name in [n for n in wb.sheetnames if n not in keep]:
        del wb[name]

    # Reorder to match `keep`, so the PDF pages follow that sequence.
    wb._sheets = [wb[name] for name in keep]
    wb.active = 0

    wb.save(dest)
    return dest

copy = trimmed_copy("workbook.xlsx", keep=["Summary", "Regional"])
print(copy)

Two details. Excel requires at least one visible sheet, so deleting everything produces a file the renderer rejects — the missing check plus a non-empty keep list covers it. And wb._sheets is the practical way to reorder; openpyxl's public move_sheet works one sheet at a time and is fiddlier for a full reordering.

Order matters more than it sounds. Sheets render in workbook order, so putting Summary first is what makes it page one rather than page thirty-eight.

Step 2 — Or just hide what you do not want

LibreOffice does not render hidden sheets, so hiding is the least invasive option — the data stays in the workbook and nothing is deleted:

Python
from openpyxl import load_workbook

def hide_except(src, dest, keep):
    """Hide every sheet except `keep`, so the PDF contains only those."""
    shutil.copyfile(src, dest)
    wb = load_workbook(dest)

    for ws in wb.worksheets:
        ws.sheet_state = "visible" if ws.title in keep else "hidden"

    visible = [ws for ws in wb.worksheets if ws.sheet_state == "visible"]
    if not visible:
        raise ValueError("at least one sheet must stay visible")
    wb.active = wb.index(visible[0])

    wb.save(dest)
    return dest

The trade-off is honesty about what you are relying on. Deleting is an explicit instruction; hiding depends on the renderer's behaviour, which is stable in LibreOffice but is not something the file format guarantees. Prefer deletion when the output must be reproducible across tools, and hiding when you also want the trimmed workbook to remain usable — the visibility mechanics are covered in hiding sheets, rows and columns.

Step 3 — Watch what openpyxl drops

Both approaches above load and re-save the workbook through openpyxl, and that is lossy in ways that matter for a document.

Which trimming route preserves the visuals Loading a workbook with openpyxl and saving it back drops charts and images that openpyxl did not itself create, so a summary sheet with a chart converts to a PDF with an empty space where the chart was. Having LibreOffice do the work instead — converting the whole workbook and then extracting the pages, or scripting the sheet removal inside LibreOffice — preserves every visual, at the cost of a slower process. The choice depends entirely on whether the sheets being kept contain charts. openpyxl round trip fast, pure Python full control of order drops charts and images also drops pivot caches fine for tables of numbers let LibreOffice do it convert the whole workbook or hide sheets, do not delete every visual survives costs a process start required when charts matter

openpyxl drops charts and images it did not create, so a summary sheet carrying a chart converts to a PDF with a blank space. Check before choosing a route:

Python
import zipfile
from pathlib import Path

def has_visuals(path):
    """True if the workbook contains charts or images openpyxl would drop."""
    with zipfile.ZipFile(path) as zf:
        names = zf.namelist()
    return any(n.startswith(("xl/charts/", "xl/media/")) for n in names)

if has_visuals("workbook.xlsx"):
    print("charts or images present — hide sheets rather than deleting them")

Hiding is the safer of the two here as well, because it still round-trips through openpyxl and therefore still drops visuals. When the kept sheets genuinely contain charts, the honest options are to convert the whole workbook and accept the extra pages, or to rebuild the charts in the trimmed copy with openpyxl — the approach in adding a combo chart with a secondary axis.

Step 4 — Convert and clean up

Wrap the whole thing so the temporary copy never outlives the call:

Python
import shutil
import subprocess
import tempfile
from pathlib import Path

def sheets_to_pdf(src, keep, out_dir="delivery", timeout=300):
    """Convert only the named sheets, in that order, to a PDF."""
    soffice = shutil.which("soffice") or shutil.which("libreoffice")
    if soffice is None:
        raise RuntimeError("LibreOffice not found")

    src = Path(src)
    out = Path(out_dir).resolve()
    out.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory() as work:
        copy = trimmed_copy(src, keep, dest=Path(work) / src.name)

        with tempfile.TemporaryDirectory() as profile:
            result = subprocess.run(
                [soffice, f"-env:UserInstallation=file://{profile}",
                 "--headless", "--convert-to", "pdf",
                 "--outdir", str(out), str(copy)],
                capture_output=True, text=True, timeout=timeout,
            )
        if result.returncode != 0:
            raise RuntimeError(result.stderr.strip())

        pdf = out / (copy.stem + ".pdf")
        if not pdf.is_file() or pdf.stat().st_size == 0:
            raise RuntimeError(f"no usable PDF at {pdf}")
        return pdf

pdf = sheets_to_pdf("workbook.xlsx", keep=["Summary", "Regional"])
print("wrote", pdf)

The nested TemporaryDirectory contexts do two jobs: the outer one holds the trimmed copy and removes it whatever happens, and the inner one gives LibreOffice a throwaway profile so concurrent runs cannot deadlock on the profile lock.

Verify the result is the size you expected — a PDF far longer than intended usually means a sheet you meant to exclude survived:

Python
import re

def page_count(pdf_path):
    data = Path(pdf_path).read_bytes()
    counts = [int(m) for m in re.findall(rb"/Count\s+(\d+)", data)]
    return max(counts) if counts else len(re.findall(rb"/Type\s*/Page[^s]", data))

pages = page_count(pdf)
assert pages <= 10, f"{pages} pages — did an unwanted sheet survive?"

Step 5 — One PDF per sheet

One invocation for every sheet, not one per sheet Producing five per-sheet PDFs two ways. A loop calling soffice once per file repeats the one-to-two-second start-up five times, so the start-up dominates the total. Passing all five trimmed copies to a single invocation starts LibreOffice once and converts them in sequence inside that one process, finishing in a fraction of the time. LibreOffice start-up converting one sheet one call per file one call, five files done here raise the timeout in proportion — the whole batch must finish inside one window

The same trimming, once per sheet, converted in a single soffice call so the start-up cost is paid once:

Python
import shutil
import subprocess
import tempfile
from pathlib import Path
from openpyxl import load_workbook

def sheet_per_pdf(src, out_dir="delivery", skip_prefixes=("_",), timeout=900):
    """One PDF per sheet, converted in a single LibreOffice invocation."""
    soffice = shutil.which("soffice") or shutil.which("libreoffice")
    if soffice is None:
        raise RuntimeError("LibreOffice not found")

    src = Path(src)
    out = Path(out_dir).resolve()
    out.mkdir(parents=True, exist_ok=True)

    names = [n for n in load_workbook(src, read_only=True).sheetnames
             if not n.startswith(skip_prefixes)]

    with tempfile.TemporaryDirectory() as work:
        copies = []
        for name in names:
            safe = "".join(c if c.isalnum() or c in "-_ " else "_" for c in name)
            copies.append(str(trimmed_copy(
                src, [name], dest=Path(work) / f"{src.stem}-{safe}.xlsx"
            )))

        with tempfile.TemporaryDirectory() as profile:
            subprocess.run(
                [soffice, f"-env:UserInstallation=file://{profile}",
                 "--headless", "--convert-to", "pdf",
                 "--outdir", str(out), *copies],
                capture_output=True, text=True, timeout=timeout, check=True,
            )

    produced = [out / (Path(c).stem + ".pdf") for c in copies]
    missing = [p.name for p in produced if not p.is_file()]
    if missing:
        raise RuntimeError(f"no PDF for: {', '.join(missing)}")
    return produced

for path in sheet_per_pdf("workbook.xlsx"):
    print(path.name)

Sanitising the sheet name into a filename matters for the same reasons as in splitting a sheet into multiple files — a sheet called Q1/Q2 produces an invalid path otherwise.

Common pitfalls and fixes

SymptomCauseFix
Whole workbook still convertedTrimmed the wrong fileConvert the copy, not the source.
Renderer rejects the fileEvery sheet deleted or hiddenKeep at least one visible sheet.
Summary is on page 30Sheets render in workbook orderReorder the copy before converting.
Chart missing from the PDFopenpyxl dropped it on saveConvert the whole workbook, or rebuild the chart.
KeyError on a sheet nameName differs by case or spacingCheck wb.sheetnames exactly.
Temporary copies left behindNo cleanup on failureUse a TemporaryDirectory context.
Conversion hangs under cronProfile lock contentionPass a fresh -env:UserInstallation.
Invalid filename per sheetSheet name has / or \Sanitise before using it as a path.

Performance and scale notes

The cost breaks into three parts: the file copy, the openpyxl round trip, and the LibreOffice conversion. The middle one is usually the surprise.

load_workbook parses the entire workbook, including the sheets you are about to delete — so trimming a 60 MB file down to one small sheet still pays the full parse. Where the unwanted sheets are the bulk of the file, read_only=True for inspection and a direct zip-level manipulation avoid that, though deleting sheets correctly from the archive means fixing several relationship parts and is rarely worth the complexity. The pragmatic answer for a recurring job is to produce the report and the working data as separate workbooks in the first place, so no trimming is needed at all.

Two habits that always help. Batch every conversion into one soffice call, since the one-to-two-second start-up otherwise repeats per file — that is the dominant cost for a set of small sheets. And skip the trim entirely when you want everything, because a straight conversion of the original avoids both the copy and the parse.

For genuinely large workbooks, note that the trimmed copy is written in full before conversion, so peak disk usage is the source plus the copy. In a container with a small writable layer, point tempfile at a volume with room:

Python
import tempfile

tempfile.tempdir = "/var/tmp/reports"       # somewhere with space

And where the goal is a short document from a large workbook, consider building the summary as its own small workbook rather than trimming a big one — that is both faster and avoids every chart-preservation question in this guide.

Conclusion

There is no flag that converts one sheet, so shape a copy and convert that. Copy the file, delete or hide what the reader should not see, reorder so the summary lands on page one, and convert the copy inside a temporary directory that cleans itself up. Check first whether the kept sheets carry charts or images — an openpyxl round trip drops them, and if they matter you must convert the whole workbook or rebuild the visuals. Then verify the page count, because a PDF much longer than expected is the clearest sign that a sheet you meant to exclude came along.

Frequently asked questions

Can LibreOffice convert just one sheet? Not through a simple flag. The reliable approach is to copy the workbook, delete the sheets you do not want, and convert the copy — which also lets you control the order they appear in.

Does hiding a sheet exclude it from the PDF? Yes, in practice — LibreOffice does not render hidden sheets. It is the least invasive method, since the data stays in the workbook, but it depends on renderer behaviour rather than an explicit instruction.

How do I control the order sheets appear in the PDF? Reorder them in the workbook copy before converting. Sheets render in workbook order, so moving the summary to the front puts it on page one.

Why does my trimmed copy lose its charts? openpyxl does not preserve charts it did not create, so loading and re-saving a workbook drops them. Convert the whole workbook, or rebuild the charts in the copy, when they must survive.

Can I produce one PDF per sheet? Yes — make one trimmed copy per sheet and convert them all in a single soffice call. Passing every file to one invocation pays the start-up cost once rather than per sheet.