FormaTeX

\begin{article}

draft — not published

Bulk LaTeX PDF Generation via API

Generate thousands of personalized PDFs from LaTeX templates using a compilation API — batch requests, concurrency control, and progress tracking.

·8 min read·
Bulk LaTeX PDF Generation via API

Generating one PDF from LaTeX is straightforward. Generating ten thousand personalized ones — certificates for an online course, invoices for a SaaS billing run, regulatory reports for each client — is where most setups fall apart. TeX Live installed locally becomes a bottleneck, shell-escaping user data turns into a security nightmare, and parallelism causes resource contention that crashes the machine. This tutorial walks through a production-grade pipeline for bulk LaTeX PDF generation using the FormatEx API: template rendering with Jinja2, concurrent API calls that respect rate limits, progress tracking, and retry logic for transient failures.

The Templating Layer: Jinja2 + LaTeX

The cleanest separation of concerns is to keep your LaTeX template as a .tex.j2 file and render it with Jinja2 before sending it to the API. LaTeX and Jinja2 use different delimiter conventions by default — configure Jinja2 to use block delimiters that will not collide with LaTeX syntax.

python
from jinja2 import Environment, FileSystemLoader

env = Environment(
    loader=FileSystemLoader("templates"),
    block_start_string=r"\BLOCK{",
    block_end_string="}",
    variable_start_string=r"\VAR{",
    variable_end_string="}",
    comment_start_string=r"\#{",
    comment_end_string="}",
    trim_blocks=True,
    autoescape=False,  # LaTeX is not HTML — never autoescape
)

A certificate template (templates/certificate.tex.j2) might look like:

latex
\documentclass[a4paper]{article}
\usepackage{fontspec}
\usepackage{geometry}
\geometry{margin=2cm}

\begin{document}
\begin{center}
  {\Huge Certificate of Completion} \\[1cm]
  {\Large This certifies that} \\[0.5cm]
  {\LARGE \textbf{\VAR{recipient_name}}} \\[0.5cm]
  {\large has successfully completed \textbf{\VAR{course_name}}} \\[0.3cm]
  on \VAR{completion_date}
\end{center}
\end{document}

Render each record:

python
template = env.get_template("certificate.tex.j2")

def render_document(record: dict) -> str:
    return template.render(**record)

Keep template rendering synchronous and cheap — it is pure string manipulation. The I/O cost comes from the API calls, which is where concurrency pays off.

Concurrent API Calls with Rate-Limit Respect

The FormatEx compile endpoint is https://api.formatex.io/api/v1/compile. Each request POSTs a JSON body with the LaTeX source and the engine, and receives a PDF binary in return. The X-API-Key header authenticates the request.

python
import httpx

FORMATEX_API_URL = "https://api.formatex.io/api/v1/compile"
FORMATEX_API_KEY = "your_api_key_here"

async def compile_latex(client: httpx.AsyncClient, latex_source: str, engine: str = "xelatex") -> bytes:
    response = await client.post(
        FORMATEX_API_URL,
        headers={
            "X-API-Key": FORMATEX_API_KEY,
            "Content-Type": "application/json",
        },
        json={
            "source": latex_source,
            "engine": engine,
        },
        timeout=120.0,
    )
    response.raise_for_status()
    return response.content

For bulk workloads, use asyncio.Semaphore to cap concurrency. The right value depends on your plan — Scale plan users can push higher, Free plan users are capped at 15 compilations per month total, so concurrency is less relevant than staying under the quota.

python
import asyncio
import httpx
from pathlib import Path

async def compile_batch(
    records: list[dict],
    output_dir: Path,
    concurrency: int = 10,
) -> dict[str, str]:
    semaphore = asyncio.Semaphore(concurrency)
    results: dict[str, str] = {}

    async def compile_one(record: dict) -> None:
        record_id = record["id"]
        latex_source = render_document(record)

        async with semaphore:
            try:
                pdf_bytes = await compile_latex(client, latex_source)
                output_path = output_dir / f"{record_id}.pdf"
                output_path.write_bytes(pdf_bytes)
                results[record_id] = "success"
            except httpx.HTTPStatusError as e:
                results[record_id] = f"http_error:{e.response.status_code}"
            except Exception as e:
                results[record_id] = f"error:{e}"

    async with httpx.AsyncClient() as client:
        await asyncio.gather(*[compile_one(r) for r in records])

    return results

The concurrency parameter is the single most important tuning knob. Start at 5, measure wall-clock time and error rate, then increase until you hit diminishing returns or HTTP 429 responses.

Retry Logic for Transient Failures

Network hiccups and temporary API overload will cause occasional failures in any bulk job. A simple exponential backoff with jitter handles the majority of transient errors without hammering the API.

python
import asyncio
import random
import httpx

async def compile_with_retry(
    client: httpx.AsyncClient,
    latex_source: str,
    engine: str = "xelatex",
    max_retries: int = 3,
) -> bytes:
    last_exception: Exception | None = None

    for attempt in range(max_retries + 1):
        try:
            response = await client.post(
                FORMATEX_API_URL,
                headers={
                    "X-API-Key": FORMATEX_API_KEY,
                    "Content-Type": "application/json",
                },
                json={"source": latex_source, "engine": engine},
                timeout=120.0,
            )

            # Don't retry client errors (4xx except 429)
            if response.status_code != 429:
                response.raise_for_status()
                return response.content

            # 429 rate limit — wait and retry
            wait = (2 ** attempt) + random.uniform(0, 1)
            await asyncio.sleep(wait)

        except (httpx.TimeoutException, httpx.NetworkError) as e:
            last_exception = e
            if attempt < max_retries:
                wait = (2 ** attempt) + random.uniform(0, 1)
                await asyncio.sleep(wait)

    raise RuntimeError(f"Failed after {max_retries} retries") from last_exception

The key distinction: retry on 429 and network errors, fail fast on 4xx client errors — your LaTeX has a syntax error, or the request is malformed, and retrying will not help. See the complete guide to LaTeX API error codes for a full breakdown of what each status code means and when to retry.

Progress Tracking for Long-Running Jobs

A batch of a thousand PDFs will take several minutes even with high concurrency. Use a simple counter for real-time feedback:

python
from dataclasses import dataclass, field
from datetime import datetime

@dataclass
class BatchProgress:
    total: int
    completed: int = 0
    failed: int = 0
    started_at: datetime = field(default_factory=datetime.utcnow)

    def record_success(self) -> None:
        self.completed += 1
        self._print_status()

    def record_failure(self) -> None:
        self.completed += 1
        self.failed += 1
        self._print_status()

    def _print_status(self) -> None:
        elapsed = (datetime.utcnow() - self.started_at).total_seconds()
        rate = self.completed / elapsed if elapsed > 0 else 0
        remaining = (self.total - self.completed) / rate if rate > 0 else float("inf")
        success = self.completed - self.failed
        print(
            f"\r[{self.completed}/{self.total}] "
            f"{success} ok, {self.failed} failed — "
            f"{rate:.1f} PDFs/s — "
            f"~{remaining:.0f}s remaining",
            end="",
            flush=True,
        )

Choosing the Right Engine for Bulk Workloads

Engine choice affects both compilation speed and font/feature availability. For bulk jobs where you are compiling thousands of documents, the engine decision has a real impact on total runtime.

EngineSpeedUnicode/FontsBest For
pdflatexFastestLimited (OT1)Simple invoices, certificates with standard fonts
xelatexMediumFull (system fonts)Branded documents, multilingual content
lualatexSlowestFull (Lua scripting)Complex typesetting, programmatic layout
latexmkVariesDepends on backendAuto-dependency resolution, bibliography

For certificate generation with custom fonts, xelatex is the right call. For plain invoices using Computer Modern or Latin Modern, pdflatex will be 2—3x faster per document.

Free plan accounts are restricted to pdflatex. Developer, Pro, and Scale plans unlock all four engines.

Putting It All Together: Invoice Generation Example

Here is a complete script that reads invoice data from a CSV, renders LaTeX templates, compiles in parallel, and writes PDFs to disk.

python
import asyncio
import csv
import sys
from pathlib import Path
from jinja2 import Environment, FileSystemLoader

env = Environment(
    loader=FileSystemLoader("templates"),
    block_start_string=r"\BLOCK{",
    block_end_string="}",
    variable_start_string=r"\VAR{",
    variable_end_string="}",
    trim_blocks=True,
    autoescape=False,
)

template = env.get_template("invoice.tex.j2")

def render_document(record: dict) -> str:
    return template.render(**record)

async def main() -> None:
    input_csv = Path(sys.argv[1])
    output_dir = Path(sys.argv[2])
    output_dir.mkdir(parents=True, exist_ok=True)

    with input_csv.open() as f:
        records = list(csv.DictReader(f))

    print(f"Compiling {len(records)} invoices...")

    progress = BatchProgress(total=len(records))
    semaphore = asyncio.Semaphore(8)

    async def compile_one(record: dict) -> None:
        latex_source = render_document(record)
        async with semaphore:
            try:
                pdf_bytes = await compile_with_retry(client, latex_source)
                output_path = output_dir / f"{record['id']}.pdf"
                output_path.write_bytes(pdf_bytes)
                progress.record_success()
            except Exception:
                progress.record_failure()

    async with httpx.AsyncClient() as client:
        await asyncio.gather(*[compile_one(r) for r in records])

    print()
    success = progress.completed - progress.failed
    print(f"Done: {success}/{progress.total} succeeded, {progress.failed} failed.")

if __name__ == "__main__":
    asyncio.run(main())

Run it:

bash
python generate_invoices.py data/invoices_june.csv output/invoices/

Plan Selection for Your Scale

Before running a bulk job, confirm your plan covers the volume:

  • Free — 15 compilations/month, pdflatex only. Fine for testing the pipeline, not for production bulk runs.
  • Developer ($12/mo) — suitable for low-volume internal tooling, all engines.
  • Pro ($49/mo) — 500 compilations/month, all engines, 10 MB input limit. Covers most small SaaS billing runs.
  • Scale ($199/mo) — 2,000 compilations/month, 25 MB input limit, priority queue. Right for weekly batch jobs in the hundreds or daily jobs in the dozens.

If you need more than 2,000 compilations per month, the enterprise plan covers 15,000. Compile usage resets monthly, so schedule your bulk jobs accordingly if you are near a plan boundary.

The pipeline described in this tutorial — Jinja2 templating, async concurrent requests, exponential backoff retries, and live progress tracking — handles the operational complexity of bulk PDF generation without requiring any local TeX installation. The FormatEx API takes care of the compilation infrastructure; your code just renders templates and collects PDFs.

Get started with a free account at formatex.io — 15 compilations included at no cost, no credit card required. When your volume grows, upgrading takes a single click.

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy