2. Turning a Live CSV Into a Typeset PDF With a Chart in 0.20 Seconds
We pulled 8 repositories from the GitHub REST API into a CSV and had Typst typeset a table and a bar chart from it in 0.20 seconds. Two things broke on the way, and one of them took the longest to diagnose.
The WJS Desk
Sep 22, 2026 · 8 min read

In part one we installed Typst and compiled a page of text. That is a nicer Markdown, and a nicer Markdown is not worth changing your workflow for. This part is the actual argument.
By the end of it you will have a single 27 line source file that reads a CSV, sorts it, typesets a table from it, and draws a bar chart from the same rows. Change the CSV, run one command, and the document is correct again. No template engine, no build script, no regeneration step. We timed the whole compile at 0.20 seconds for a page with a table and a chart on it.
Two things broke while we built it. One is a five second fix once you know it. The other one reported an error inside a file we had never opened, and that is the one worth reading carefully.
Why this matters more than the compile speed
The common shape of a data-bearing document is three moving parts: your data lives in a database or an API, a script pulls it and formats it into a table, and your document includes that generated file. That means a build order, a stale-output failure mode, and a script that nobody wants to touch.
Typst collapses it, because the document language is a real scripting language. csv(), json(), yaml(), toml() and xml() are built in, and you get map, fold, sort, closures and dictionaries. The data loading happens during typesetting, so the only build step is the compile you were already running.
The generated-table-plus-Makefile pattern does not get easier here. It stops existing.
Getting real data
We did not want to demo this on a file we had invented, so we pulled real numbers from the GitHub REST API for eight repositories in the publishing and site-building space. This needs the gh CLI and costs nothing:
mkdir -p /tmp/wjs-typst
cd /tmp/wjs-typst
echo "name,stars,language,license,open_issues,pushed" > repos.csv
for r in typst/typst getzola/zola rust-lang/mdBook Pagefind/pagefind marp-team/marp-cli quarto-dev/quarto-cli slidevjs/slidev jgm/pandoc; do
gh api repos/$r --jq '[.full_name, .stargazers_count, (.language // "n/a"), (.license.spdx_id // "none"), .open_issues_count, (.pushed_at | split("T")[0])] | @csv'
done >> repos.csv
cat repos.csv
Nine lines, one header and eight rows. On the day we ran it, typst/typst was at 56,184 stars with 1,292 open issues, and jgm/pandoc at 46,380 with 1,041. Those are the numbers in every figure below, so if you run this yourself your document will differ, which is rather the point.
Reading it in Typst
Here is the whole first version:
#set page(paper: "a4", margin: 2cm)
#set text(font: "Libertinus Serif", size: 10pt)
#let rows = csv("repos.csv", row-type: dictionary)
= Publishing tool repositories
Pulled from the GitHub REST API on #datetime.today().display("[year]-[month]-[day]").
#rows.len() repositories.
#table(
columns: (2fr, 1fr, 1fr, 1fr),
align: (left, right, left, right),
table.header([*Repository*], [*Stars*], [*Language*], [*Open issues*]),
..rows.map(r => (r.name, r.stars, r.language, r.open_issues)).flatten()
)
Three things are doing the work. row-type: dictionary makes each row a dictionary keyed by the header names, so you write r.stars instead of r.at(1) and your code survives a column being inserted. .map() turns each row into an array of cells. And .. is the spread operator, which unpacks that array into the table function's arguments, because table takes cells as a flat argument list.
Compile it:
typst compile report.typ
First run 0.42 seconds, then 0.09 and 0.09 on repeats. The PDF is 24,788 bytes.
Pro tip: #rows.len() in the prose is not a comment, it is the actual row count printed into the sentence. Any number you would otherwise have to remember to update by hand can be computed from the source data instead, including ones inside paragraphs.
What broke: everything from a CSV is a string
We wanted a total at the bottom of the table, so we wrote the obvious thing:
#let rows = csv("repos.csv", row-type: dictionary)
Total: #rows.fold(0, (a, r) => a + r.stars)
And got:
error: cannot add integer and string
┌─ typeerr.typ:2:31
│
2 │ Total: #rows.fold(0, (a, r) => a + r.stars)
│ ^^^^^^^^^^^
Every value that comes out of csv() is a string, always. There is no type inference, no numeric column detection, no options to turn it on. 56184 from a CSV is the text "56184". This is the correct design, because guessing types on parse is how spreadsheets eat phone numbers, but it will catch you on your first arithmetic and your first sort.
The fix is int(), everywhere you do maths or comparison:
#let total = rows.fold(0, (acc, r) => acc + int(r.stars))
Total stars across the table: *#total*.
The same trap hits sorting, and there it is silent. We ran both sorts over the same eight rows and printed the star counts in order:
#let strsort = rows.sorted(key: r => r.stars).map(r => r.stars)
#let intsort = rows.sorted(key: r => -int(r.stars)).map(r => r.stars)
The string sort returned 17463, 22160, 3828, 46380, 48795, 5477, 56184, 6015. That is lexicographic order, it is nonsense as a ranking, and it raised nothing at all. The integer sort returned 56184, 48795, 46380, 22160, 17463, 6015, 5477, 3828, which is what anyone reading the chart would assume they were looking at. What you want is:
#let sorted = rows.sorted(key: r => -int(r.stars))
The minus gives you descending order without needing a comparator.
Adding a chart, and the version clash
Typst has no built-in charting. Drawing comes from CeTZ, a community package, with cetz-plot on top of it for charts. Packages are imported by name and version and downloaded on first use, which we cover properly in part three. For now:
#import "@preview/cetz:0.5.2"
#import "@preview/cetz-plot:0.1.2": chart
#cetz.canvas({
chart.barchart(
size: (12, 8),
mode: "basic",
label-key: 0,
value-key: 1,
sorted.map(r => (r.name, int(r.stars))),
)
})
Typst downloaded both packages and then said this:
error: dictionary does not contain key "tags"
┌─ @preview/cetz:0.5.2/src/process.typ:30:39
│
30 │ if drawable.TAG.no-bounds in d.tags {
│ ^^^^
while calling `element` at @preview/cetz:0.5.2/src/process.typ:100:12
while calling `many` at @preview/cetz:0.5.2/src/canvas.typ:82:33
Nothing in that error is in a file we wrote. Scroll up in the download log and the cause is visible: it fetched cetz 0.5.2 because we asked for it, and then fetched cetz 0.4.0 as well, because cetz-plot 0.1.2 imports that version internally. Two incompatible copies of the same drawing library, one producing shapes the other cannot read.
Typst has no lockfile and no dependency resolver. Each import pins its own version, transitive dependencies bring their own, and nothing warns you that two of them are the same library. We found the fix by reading the package's source in the cache:
grep -rh "@preview/cetz" ~/Library/Caches/typst/packages/preview/cetz-plot/0.1.4/src/*.typ
That printed #import "@preview/cetz:0.5.2": *. So cetz-plot 0.1.4 expects exactly the cetz we had, and bumping one character fixed it:
#import "@preview/cetz-plot:0.1.4": chart
After that, 0.23 seconds on the first run and 0.20 on repeats, producing a 33,338 byte one page PDF with an eight row table and a labelled horizontal bar chart with a gridded axis.
Confirmed: a table and a chart built from live API data, 27 lines of source, 0.20 seconds, 33,338 bytes. Regenerating the CSV and recompiling is two commands and well under a second.
One honest note on the output: the default CeTZ palette assigns bar colours in a sequence that has nothing to do with the sort order, so our descending chart has bars in a visually random set of reds. It looks like a bug and is not one. You set colours explicitly if you care.
Getting data back out
The reverse direction is less known and genuinely useful. You can annotate a document with structured metadata and query it from the shell, which is how you build an index, a search payload, or a CI check over a directory of documents.
cat > meta.typ <<'EOF'
#metadata((slug: "typst-course", parts: 4, wordcount: 8200)) <wjs>
= Doc
Body text.
EOF
typst eval 'query(<wjs>).first().value' --in meta.typ
That printed {"slug":"typst-course","parts":4,"wordcount":8200} as JSON on stdout.
Two warnings here, both learned by getting them wrong. First, almost every tutorial and blog post you will find uses typst query, and in 0.15.1 that subcommand is deprecated. It still runs, and it prints warning: the typst query subcommand is deprecated with the typst eval equivalent. Second, typst eval does not resolve layout context. We tried to get a page count out of a document with typst eval 'context counter(page).final()' and got back {"func":"context"}, which is the unevaluated expression. If you want a page count from the command line, you compile the PDF and count it there.
Common mistakes
- Doing arithmetic on CSV values. Wrap them in
int()orfloat(). The error is at least loud. - Sorting CSV values. This one is silent and produces a wrong document.
sorted(key: r => -int(r.stars)). - Forgetting the spread operator.
table(rows.map(...))passes one array as one cell. It is..rows.map(...).flatten(). - Mixing package versions. If an error points inside a package's own source, check whether two versions of the same library got pulled in.
- Copying
typst queryfrom a blog post. Usetypst eval 'query(...)' --in file.typ. - Assuming the data file is tracked. Typst reads
repos.csvat compile time. If you commit the.typand not the CSV, the build fails on somebody else's machine.
The escape hatch
If the scripting gets away from you, the fallback is the one you already have: generate the table markup with whatever language you like and #include the file. Typst does not force the data loading on you, and a .typ file of literal table rows is still a perfectly ordinary Typst document. Going halfway is allowed, which matters if you are migrating something that already works.
What we did not test
Our CSV is eight rows. We did not test a ten thousand row table, and we would expect the compile time in this part to stop being 0.20 seconds well before that. We did not test json(), yaml(), toml() or xml(), only csv(). We did not test the chart types beyond barchart, and cetz-plot is a community package at version 0.1.4 whose own metadata targets Typst compiler 0.13.1 rather than the 0.15.1 we ran it on. It worked for us. That is not the same as being supported.
Next
Part three is packages and templates: the 1,617 package registry, what happens when you run the most popular CV template on a clean Mac (it produces a PDF with five missing font families and does not tell you), how the offline cache actually behaves, and a real worked example that batch-generates eight branded PDFs from one template in 0.95 seconds.


