Convert an Excel Sheet to a LaTeX Table with Python
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
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
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.
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:
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:
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:
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.
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{}:
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:
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}.
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:
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:
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_latexdoes not escape by default; passescape=Trueor use the Styler withescape="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 needlongtable. - 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.
Related
- Up one level: Handling Excel File Formats and Conversions — converting workbooks to and from other formats.
- Convert an Excel Sheet to a Markdown Table — the lightweight cousin for docs and READMEs.
- Convert an Excel Sheet to an HTML Table with Python — the same Styler, different output.
- Export a Styled pandas DataFrame to Excel — the Styler going the other way.
More on file formats & conversion
- How-toConvert Excel Files to Parquet with Python
- How-toConvert Excel to CSV with Python
- ComparisonExcel vs CSV vs Parquet for Python Data Pipelines
- ComparisonPick an Excel Engine for .xlsx, .xlsm, .xls, .xlsb and .ods
- FixFix Garbled Characters Like "Café" and "’" in Excel Data
- How-toExtract VBA Macro Code from an Excel File