Setting a column of numbers with r is the most common mistake in LaTeX tables. Right-align 182.5, 95.0, 1450.25 and 7 and the decimal points come out exactly one digit (5pt) apart, while the lone 7 lands in the tenths place instead of the units. You can measure it. The fix is one character in the column specification: switch to the siunitx S column and the decimal markers line up to within 0.001pt. This page covers that alignment, then reading a .csv at compile time with csvsimple, pgfplotstable and datatool, and tabularray’s modern take. The principle throughout: leave the data in the data file and have LaTeX read it.
Aligning numbers on the decimal point — the siunitx S column
Load \usepackage{siunitx} and replace the r in the column specification with S. The S column parses the number and aligns it on the decimal marker, so a column whose entries have different digit counts becomes readable. Read the glyph coordinates back out of the PDF and the difference is stark. In an r column the decimal points of 182.5 and 95.0 sit at x = 62.0 while the one in 1450.25 sits at x = 57.0 — about 5pt apart, exactly one digit’s width — and the 7 is placed at the tenths position rather than the units. Set the same data as S[table-format=4.2] and all four units digits end at x = 122.11, with every decimal marker starting from the same place. The spread is under 0.001pt.
\usepackage{siunitx}
\usepackage{booktabs}
% table-format = <integer digits>.<decimal digits> of the widest entry
\begin{tabular}{l S[table-format=4.2]}
\toprule
Sample & {Mass / \unit{\gram}} \\
\midrule
A & 182.5 \\
B & 95.0 \\
C & 1450.25 \\
D & 7 \\
\bottomrule
\end{tabular}Two things matter. First, set table-format=<integer digits>.<decimal digits> to the largest value in the column. A bare S still works, but it reserves width less well — in the same example the column swelled from 88.44pt to 98.44pt. Under-specifying, as in table-format=2.1, produces no error at all in siunitx 3 and the alignment still holds; the number simply overflows the space reserved for it and is liable to collide with the next column. Second, protect anything that could be taken for a number with braces {…}. The siunitx manual puts it exactly: if the material could be mistaken for part of a number, it should be protected by braces. Conversely, ordinary text such as Sample is now centred correctly in v3 even without braces.
Here is what “could be mistaken” looks like, measured. Write a heading as 2024 sales without braces and siunitx picks up the leading 2024 as the number, aligns it with the figures in the other rows, and sets the remaining sales as trailing material. The gap between 2024 and sales opens to 7.75bp, against 3.32bp — an ordinary word space — when the whole thing is wrapped as {2024 sales}. No error is raised: only the appearance breaks, so do not forget the braces on a heading that contains a number. Inside the argument of \multicolumn or \multirow you cannot use an S column at all; there you reach for the macro form \tablenum[table-format=4.2]{1450.25}, which the manual describes as, in effect, a macro version of the S column.
The siunitx package at large — the syntax of \num, \qty and \unit, uncertainties written in the bracket form such as \num{1.234(5)}, exponents, and what changed between v2 and v3 — belongs to the “Units (siunitx)” page. Divide the labour as \qty for quantities in the prose and the S column for numbers in tables, and the numeric style stays consistent across the whole document. The one thing to remember on the table side is a typographic convention: put the unit in the column heading, not in every cell. The booktabs manual lists the same rule among its guidelines.
Why have LaTeX read the CSV
Because then you do not edit the manuscript every time a digit changes. Experimental results and summary tables almost always leave a spreadsheet or an instrument as CSV (comma-separated text). Copying that into tabular cells by hand is tedious, and every added row and every corrected value is another chance to mistype. Turn the idea around — leave the data in the data file and tell LaTeX to read and typeset it — and updating the data and recompiling is all it takes for the table to follow. Reuse the same CSV in the body, in slides and in an appendix as often as you like, with no transcription errors. It is a direct extension of LaTeX’s habit of separating logical structure from appearance.
Every example on this page uses the small CSV file below. Its first line is the header row, naming the columns product, price and weight; the rest is data. Save it as data.csv beside your .tex and the code that follows compiles as it stands.
product,price,weight
Apple,380,182.5
Orange,120,95.0
Melon,1280,1450.25Reading a CSV with csvsimple
Load \usepackage{csvsimple} and write the single line \csvautotabular{data.csv}, and the whole CSV becomes a tabular. The first line is set as a ruled heading, which is plenty when you just want to look at the contents. What it does not give you is control over the formatting. To decide alignment, rules and which columns to print, use \csvreader — the real workhorse of the package. One detail worth knowing: in v2.6.0, shipped with TeX Live 2024, a plain \usepackage{csvsimple} loads the older implementation csvsimple-legacy — csvsimple.sty contains the line \SetKeys{ legacy }. If you want the LaTeX3 implementation, ask for it: \usepackage[l3]{csvsimple} or \usepackage{csvsimple-l3}.
\usepackage{csvsimple}
\usepackage{booktabs}
\usepackage{siunitx}
% NOTE the braces around the column spec: an unbraced S[...] breaks the key list
\csvreader[
tabular = {l r S[table-format=4.2]},
table head = \toprule Product & {Price} & {Weight} \\ \midrule,
table foot = \bottomrule,
late after line = \\]
{data.csv}
{product=\product, price=\price, weight=\weight}
{\product & \price & \weight}The four parts of \csvreader[options]{file}{assignments}{body} are, in order, the file to read, the binding of column names to macros, and what to emit for each row. Write price=\price and \price expands to that row’s value inside the body. The surrounding frame comes from the options: tabular= is the column specification, table head= the heading row, table foot= the closing material, and late after line = \\ appends a row terminator \\ to each line — the idiom that avoids a stray break after the last row. When header names contain spaces or symbols, leave the assignments empty and address the columns by number with \csvcoli, \csvcolii, \csvcoliii — the first, second and third column.
Here is the one real trap on this page. The braces around the value of tabular in the example above are not decoration. Write it bare, as tabular = l r S[table-format=4.2], and the comma that separates key–value pairs collides with the comma inside S[…]; the run stops with ! Paragraph ended before \NC@rewrite@S was complete. It then goes on to report ! Missing $ inserted. and ! Package csvsimple Error: File ',' not existent…, which makes it hard to see that the column specification is at fault. Brace it — tabular = {l r S[table-format=4.2]} — and it compiles. The safe habit is broader than S: whenever a column specification with square brackets goes inside a key–value list, wrap it in braces.
By default the first line is treated as a header and excluded from the data. To read a CSV that has no header row, the starred form \csvreader* takes the first line as data too. Beyond that there is filter for selecting rows by condition, and \csvstyle / \csvnames for reusing a set of assignments — facilities that reach past tables into per-row processing generally, such as generating address labels from a contact list.
Engineering the number format with pgfplotstable
If you want to design how the numbers themselves look, pgfplotstable is the most powerful option. Part of pgfplots, it is loaded with \usepackage{pgfplotstable} and has one central command: \pgfplotstabletypeset[options]{data.csv}. It reads the CSV, formats it to the requested precision and number style, and assembles a tabular internally as its output. To read a CSV you must declare the separator with col sep=comma — the default is space-separated. Everything is controlled through key–value options.
| Option | What it does |
|---|---|
col sep=comma | Read as CSV (comma-separated); the default is space-separated |
header=has colnames | Treat line 1 as column names; header=false means no header row |
columns | columns={a,b,…} selects which columns to print, and in what order |
columns/NAME/.style | columns/price/.style={…} applies formatting to one named column |
column name | Replaces the printed heading, independently of the CSV column name |
fixed | Fixed-point; fixed zerofill pads trailing zeros and precision=n sets the decimals |
sci | Set in scientific (exponent) notation; sci zerofill pads the mantissa |
string type | A text column; no number formatting is applied at all |
dec sep align | Aligns the column on the decimal point (needs array) |
\usepackage{pgfplotstable}
\usepackage{booktabs}
\pgfplotsset{compat=1.18}
\pgfplotstabletypeset[
col sep = comma,
header = has colnames,
columns = {product, price, weight},
columns/product/.style = {string type, column name = Product},
columns/price/.style = {column name = Price, fixed, precision = 0},
columns/weight/.style = {column name = {Weight / g}, fixed, fixed zerofill,
precision = 1, dec sep align},
every head row/.style = {before row = \toprule, after row = \midrule},
every last row/.style = {after row = \bottomrule},
]{data.csv}The product column is string type (text), price is an integer (precision=0), and weight is set to one decimal with trailing-zero fill and aligned by dec sep align. Headings are replaced with column name, and the rules come from booktabs commands injected through every head row and every last row. Change precision alone and the same data changes its digit count — controlling the look of the numbers without touching the CSV is exactly what pgfplotstable is for. It also calls for care: with these settings 1450.25 prints as 1,450.3, rounded. Dropping digits is a formatting instruction and the rounding happens silently, so choose your significant figures deliberately. (Add 1000 sep={} if you do not want the thousands separator.)
pgfplotstable can also derive computed columns from the ones it reads: define a column that is calculated when used with create on use, or post-process values inside columns/…/.style via postproc cell content. In short, spreadsheet-like work done entirely inside LaTeX. All that power makes the syntax heavy, so the rule of thumb is pgfplotstable for elaborate numeric tables, csvsimple for a plain CSV-to-table. If you also plot the same CSV, sharing machinery with pgfplots’ \addplot table is a further argument in its favour.
datatool — treating the CSV as a database
The third package, datatool, reads a CSV as a database and excels at per-row processing — mail-merge–style work. Load \usepackage{datatool} and write \DTLloaddb{name}{data.csv} to pull the file into a named database. By default the first line is the header, and its column names become the keys for each value. For a CSV without a header, \DTLloaddb[noheader]{…}{…} names the columns Column1, Column2, … automatically. Once loaded, \DTLforeach{name}{assignments}{body} walks the rows in turn. Assignments are written “macro = column name”, as in \DTLforeach{db}{\Product=product,\Price=price}{…}, and inside the body \Product and \Price expand to that row’s values.
\usepackage{datatool}
\usepackage{booktabs}
\DTLloaddb{goods}{data.csv}
% the row break goes at the START of the loop body, not the end
\begin{tabular}{l r}
\toprule
Product & Price
\DTLforeach{goods}{\Product=product, \Price=price}{%
\\ \Product & \Price}
\\ \bottomrule
\end{tabular}Here the CSV is loaded as goods, and inside the tabular the \DTLforeach emits “product & price” for each row. Note that the row terminator \\ sits at the start of the body, not the end. Put it at the end and the last iteration opens an empty row, so the \bottomrule (or \hline) that follows lands inside a cell of that row and the run stops with ! Misplaced \noalign. Leading it, and adding one \\ after the loop, is the safe form. datatool’s real strength lies more in manipulating data than in setting tables: it can total and average numbers, sort, and exclude rows by condition through macros, and it is used to generate bibliographies and merged documents. Conversely, if all you need is to turn a CSV into a table, csvsimple is more concise.
Aligning numbers with tabularray’s Q column
Load \UseTblrLibrary{siunitx} and tabularray brings the same decimal alignment as the S column into the tblr environment, in the form Q[si={table-format=4.2}]. Add \UseTblrLibrary{booktabs} and \toprule / \midrule / \bottomrule work as usual, while formatting is given entirely in key–value form, as in colspec or row{1}={font=\bfseries}. The big attraction is not having to relearn syntax such as >{…} or \multirow. But tabularray itself — the design of tblr, width=, cell merging, rowsep and colsep — belongs to the “Advanced table environments” page, so this section stays on numeric columns only.
\usepackage{tabularray}
\UseTblrLibrary{booktabs}
\UseTblrLibrary{siunitx}
\begin{tblr}{colspec = {l r Q[si={table-format=4.2}]}}
\toprule
Product & Price & {{{Weight}}} \\
\midrule
Apple & 380 & 182.5 \\
Orange & 120 & 95.0 \\
Melon & 1280 & 1450.25 \\
\bottomrule
\end{tblr}Which one to use
Choose by purpose and the decision is quick. If the data runs to a few dozen rows and you only want the numbers in a hand-written table to line up, the S column is enough. If the CSV keeps changing, move to one of the readers. A rough guide follows.
- Just want the decimal points to line up — the
siunitxScolumn. It drops straight into a hand-writtentabular; use\tablenuminside\multicolumn. - Plain CSV to table —
csvsimple.\csvautotabularfor an instant table,\csvreaderwhen you need control over alignment, rules and which columns appear. - Engineering digit counts and number styles, or computed columns —
pgfplotstable. The most powerful, but the syntax is heavy and rounding happens silently. - Data manipulation is the point (totals, sorting, conditional processing, merged documents) —
datatool. - You want key–value syntax, or a numeric column in an existing
tblr—tabularraywith thesiunitxlibrary andQ[si={…}].
Whatever the method, what finally comes out is the same tabular vocabulary — the column spec, &, \\ and rules. If the reading works but the table is still hard to read, the cause is usually on the rules side: swap \hline for the three booktabs rules and move the units into the column headings, and the same data becomes far easier to read. For a table too wide for the page see “Advanced table environments”, for one that runs over a page break see “Page-spanning tables”, and for captions and placement see “Table placement & styling”.