How-to
Getting Started With Python Excel AutomationDeep dive

Convert an Excel Sheet to a LaTeX Table with Python

Turn a range from a workbook into a LaTeX table that compiles first time: read it with pandas, write it with to_latex or the Styler, escape the characters that break LaTeX (& % _ $ #), format numbers and percentages without letting a bare % comment out the row, add booktabs rules, a caption and a label, split long tables across pages, and check the result with pdflatex.

Results that live in a workbook often end up in a paper, a thesis, a technical report or a PDF built from LaTeX. Retyping a table is slow and error-prone, and online converters choke on anything beyond a small grid. pandas writes LaTeX directly — DataFrame.to_latex and the more flexible Styler.to_latex — so the table can be regenerated whenever the workbook changes. The catch is that LaTeX treats several ordinary characters as commands: an ampersand in a region name splits the row into an extra column, an underscore in a product code stops compilation, and a percent sign silently comments out the rest of the line. This page shows the conversion, the escaping that prevents those failures, and a compile check that proves the output works. It belongs to Handling Excel File Formats and Conversions.

Prerequisites

Bash
pip install pandas openpyxl jinja2

pandas 2 and later build LaTeX through the Styler, which needs jinja2. A LaTeX installation (pdflatex) is only needed for the compile check in Step 6. results.xlsx has a Summary sheet whose table starts on row 3, below a title, with a region called North & East and one called West_2 — two names that break naive conversions.

Step 1 — Read the range and look at the default output

Python
import pandas as pd

df = pd.read_excel("results.xlsx", sheet_name="Summary", header=2)
print(df.to_latex(index=False))

header=2 skips the title rows, as described in Skip Rows and Set the Header When Reading Excel with pandas. The default output is a tabular with booktabs rules and six decimal places on every number. It also contains North & East and West_2 exactly as typed: DataFrame.to_latex does not escape by default, so the ampersand becomes a column separator — the row now has five cells for four columns — and the underscore triggers "Missing $ inserted" when the document compiles.

Unescaped text changes the table's structure The Excel row North & East with revenue 1234567.89 becomes, without escaping, a LaTeX row where the ampersand is read as a column separator and the region splits into two cells. With escaping the ampersand is written as a backslash ampersand and stays text. Region North & East West_2 escape off escape on North & East & North \& East & West_2 & West\_2 & Excel cell LaTeX row (escape off / on) to_latex

Step 2 — Escape special characters

LaTeX reserves ten characters: & % $ # _ { } ~ ^ \. Passing escape=True replaces each with its safe form, in the data and in the column headers:

Python
print(df.to_latex(index=False, escape=True, float_format="%.2f"))

North \& East and West\_2 now print as text, and float_format cuts the noise to two decimals. For a quick table that is enough. Reports usually want different formats per column — thousands separators on revenue, percentages for growth and margin — and that is where the Styler comes in.

Step 3 — Format each column with the Styler

df.style.format takes a format per column and an escape option; format_index does the same for the header row:

Python
styled = (
    df.style.hide(axis="index")
    .format({"Revenue": "{:,.0f}", "Growth": "{:+.1%}", "Margin": "{:.1%}"}, escape="latex")
    .format_index(escape="latex", axis=1)
)
print(styled.to_latex(hrules=True, column_format="lrrr"))

Look closely at the output: the region names are escaped, but the percentages read +12.5%. The escape option escapes the cell value before formatting, not the text the format string produces, so the % added by {:.1%} goes through raw — and in LaTeX % starts a comment, so everything after it on that line, including the \\ that ends the row, disappears. The table does not fail loudly; it runs rows together. Produce percentages with an escaped sign instead:

Python
pct = lambda v: f"{v * 100:+.1f}\\%"
styled = (
    df.style.hide(axis="index")
    .format({"Revenue": "{:,.0f}", "Growth": pct, "Margin": lambda v: f"{v * 100:.1f}\\%"})
    .format(escape="latex", subset=["Region"])
    .format_index(escape="latex", axis=1)
)
body = styled.to_latex(hrules=True, column_format="lrrr")
print(body)

Callables give complete control over the text: each one returns a string with \%, which LaTeX prints as a percent sign. Escaping is applied only to the text column, so the backslashes the callables add are not escaped a second time. hrules=True adds the booktabs \toprule, \midrule and \bottomrule; column_format="lrrr" left-aligns the text column and right-aligns the numbers so the digits line up.

Why a percentage format can swallow the end of a row A LaTeX table row produced with the format {:.1%}. The percent sign after 12.5 starts a comment, so the row terminator and everything after it on the line are ignored. Writing backslash percent keeps the row intact. 1 1 North \& East & 1,234,568 & +12.5% & 21.4% \\ 2 2 North \& East & 1,234,568 & +12.5\% & 21.4\% \\ 1 % starts a comment: & 21.4% \\ is ignored 2 \% prints a percent sign and the row ends

Step 4 — Wrap it in a table with a caption and a label

A float environment gives the table a number, a caption and a label for \ref{}:

Python
table = styled.to_latex(
    hrules=True, column_format="lrrr",
    caption="Revenue, growth and margin by region, FY2026",
    label="tab:regions", position="htbp", position_float="centering",
)
print(table)

The output is a complete table environment with \centering, \caption{…} and \label{tab:regions}; drop it into a document with \input{regions.tex}. Writing the table to its own file and inputting it means the document picks up new numbers whenever the script reruns, without anyone editing the .tex source.

Step 5 — Long tables that span pages

A table float cannot break across pages, so a 200-row listing runs off the bottom. Use the longtable environment, which repeats the header on every page:

Python
rows = pd.concat([df] * 30, ignore_index=True)
long_table = (
    rows.style.hide(axis="index")
    .format({"Revenue": "{:,.0f}", "Growth": pct, "Margin": lambda v: f"{v * 100:.1f}\\%"})
    .format(escape="latex", subset=["Region"])
    .format_index(escape="latex", axis=1)
    .to_latex(environment="longtable", hrules=True, column_format="lrrr",
              caption="All regional rows", label="tab:long")
)
print(long_table.count(r"\\"), "row terminators;", "endhead" in long_table)

The output includes \endhead, which tells LaTeX to repeat the header block at the top of each page. It requires \usepackage{longtable} in the preamble; booktabs rules need \usepackage{booktabs}.

Which LaTeX environment to generate Short tables that fit on a page go into a table float with a caption and label. Long listings use longtable so the header repeats. Tables embedded in running text or a minipage use a bare tabular. How will the table be used? fits on a page table float caption, label, position=htbp many rows longtable header repeats via \endhead inside other content bare tabular default to_latex output

Step 6 — Prove that it compiles

The cheapest test is to compile a minimal document around the table. A failed compile names the line, which points straight at the unescaped character:

Python
from pathlib import Path
import subprocess

Path("regions.tex").write_text(table)
Path("check.tex").write_text(
    "\\documentclass{article}\n\\usepackage{booktabs}\n\\usepackage{longtable}\n"
    "\\begin{document}\n\\input{regions.tex}\n\\end{document}\n"
)
result = subprocess.run(["pdflatex", "-interaction=nonstopmode", "-halt-on-error", "check.tex"],
                        capture_output=True, text=True)
print("compiled" if result.returncode == 0 else result.stdout[-800:])
print(Path("check.pdf").exists())

-halt-on-error makes pdflatex stop at the first problem instead of guessing, and the return code tells the script whether the table is valid. Running the same check on the unescaped output from Step 1 stops at the North & East row with ! Extra alignment tab has been changed to \cr. — LaTeX found one more & than the column specification allows. Wire this into the report's tests, like the output checks in Test Excel Output with pytest.

Step 7 — Regenerate every table from the workbook

Once one table works, generate all of them in a single pass so the document never shows stale numbers. Keep a small mapping from sheet to output file and caption, and write each table next to the document source:

Python
specs = {
    "Summary": ("tab-regions.tex", "Revenue, growth and margin by region", "tab:regions"),
}
out_dir = Path("tables")
out_dir.mkdir(exist_ok=True)
for sheet, (fname, caption, label) in specs.items():
    data = pd.read_excel("results.xlsx", sheet_name=sheet, header=2)
    tex = (
        data.style.hide(axis="index")
        .format({"Revenue": "{:,.0f}", "Growth": pct, "Margin": lambda v: f"{v * 100:.1f}\\%"})
        .format(escape="latex", subset=["Region"])
        .format_index(escape="latex", axis=1)
        .to_latex(hrules=True, column_format="lrrr", caption=caption, label=label,
                  position="htbp", position_float="centering")
    )
    (out_dir / fname).write_text(tex)
print(sorted(p.name for p in out_dir.iterdir()))

The document then uses \input{tables/tab-regions.tex}, and a build step — a Makefile rule, a CI job or simply running the script before pdflatex — refreshes every table from the current workbook. Because the formats live in the script, a new quarter's numbers come out with the same separators, signs and alignment as the last. If the workbook is produced by another report job, chain the two so the tables are rebuilt whenever the data is, in the same way Generate Word Documents from Excel Rows feeds a document template.

Common pitfalls

  • Unescaped text. DataFrame.to_latex does not escape by default; pass escape=True or use the Styler with escape="latex".
  • Percent signs from format strings. {:.1%} output is not escaped and comments out the rest of the row; return \% from a callable.
  • Double escaping. Escaping a column whose formatter already added backslashes produces \textbackslash{}; escape only the text columns.
  • Missing packages. Booktabs rules need booktabs; long tables need longtable.
  • Index columns. Hide the DataFrame index unless it carries meaning, or the first column will be 0, 1, 2…

Performance notes

Generating LaTeX is fast — the Styler renders a few thousand rows in well under a second — so the time goes into reading the workbook and compiling the document. Tables of several hundred rows are rarely what a reader wants in a PDF anyway; summarise them first with a pivot, and put the full listing in an appendix built with longtable, or attach the workbook instead.

Conclusion

Read the range with pandas, then generate LaTeX with the Styler: format each column, escape text columns and headers with escape="latex", return percentages with an escaped \%, add booktabs rules, and wrap the table in a float with a caption and label — or in longtable for long listings. Write it to its own .tex file, \input it, and compile a minimal document in a test so a stray ampersand never reaches the final PDF.

Frequently asked questions

How do I convert an Excel table to LaTeX in Python? Read the sheet with pd.read_excel, then call df.to_latex(index=False, escape=True) for a simple tabular, or the Styler (df.style.format with to_latex) for per-column number formats, captions, labels and booktabs rules.

Why does my LaTeX table have an extra column? A cell contains an unescaped ampersand, which LaTeX reads as a column separator. Escape special characters with escape=True in to_latex or escape="latex" in Styler.format.

Why do rows run together after formatting percentages? A format such as {:.1%} adds a bare %, which starts a LaTeX comment and hides the row terminator. Format percentages with a function that returns an escaped % instead.

How do I make a LaTeX table from pandas span several pages? Pass environment="longtable" to the Styler to_latex method, which repeats the header on every page through \endhead, and load the longtable package in the document preamble.

More on file formats & conversion

More on file formats & conversion →