
A recurring report needs a stable input, readable pages, and a clear answer to what happens when the job runs again. Python can generate a PDF directly from structured data without running a browser or copying values into a document by hand.
This guide builds a small daily order report with fpdf2. The downloadable project validates a fixed JSON snapshot, adds a total and a table, repeats the headings across pages, and writes one predictable output file. It uses synthetic records so you can reproduce the result without a database or customer data.
Validation status, November 11, 2026: Sixteen local tests passed on Windows with Python 3.12.14 and fpdf2 2.9.0. We generated and visually inspected the two-page sample, a one-page empty report, and a five-page, 100-row report. API delivery, worker queues, scheduling, Linux, concurrent execution, crash recovery, and Hivenet deployment remain untested.
The example takes 40 orders and produces an A4 document with a report date, row count, total, customer names, and amounts in USD. The expected total is 830.00 USD. The output is orders-2026-11-01.pdf, named from the supplied report date rather than the time the script happens to run.
Download the Python report project or inspect the generated sample PDF. The archive includes the complete script, fixed input, pinned dependencies, tests, and a Unicode font with its license. The commands below assume you have extracted the archive and opened a terminal in that directory.
This is programmatic PDF creation from data. It does not convert an existing HTML page, export Python source code as a document, or provide an invoice template. Keep the first report small enough to inspect; add charts or more complex page layouts only when they help a reader answer a specific question.
The package is named fpdf2, while its Python import is fpdf. Start in a fresh virtual environment to keep the project separate from other PDF packages. The sample pins fpdf2 2.9.0 and its tested dependencies. The release on PyPI is the version used here.
Create the environment with Python 3.12:
python -m venv .venv
On Windows PowerShell, use the environment's Python directly:
.venv\Scripts\python.exe -m pip install -r requirements.txt
.venv\Scripts\python.exe report.py orders.json --output-dir reports
On Linux or macOS, the corresponding commands are shown below. This path convention is provided for adaptation; those operating systems were not exercised for this article.
.venv/bin/python -m pip install -r requirements.txt
.venv/bin/python report.py orders.json --output-dir reports
The script prints the completed file path. Open the PDF and compare its total and last order with the input. A successful command exit proves that the program reached completion, but it does not establish that every label is readable or every page break is useful.
The input separates the report date and currency from the order rows. Here is a shortened illustration of its structure; the downloadable file contains all 40 records:
{
"report_date": "2026-11-01",
"currency": "USD",
"orders": [
{"order_id": "ORD-0001", "customer": "Málaga studio", "amount": "1.25"},
{"order_id": "ORD-0002", "customer": "Łódź workshop", "amount": "2.25"}
]
}
The validator rejects duplicate order IDs, invalid dates, unexpected fields, unsupported characters, and amounts that are negative or lack exactly two decimal places. Amounts arrive as strings and become Decimal values before summation. Sorting by order ID keeps the row order stable when the same records arrive in a different sequence.
The sample accepts only USD and at most 500 rows. These are deliberate example boundaries, not a currency-conversion feature or a capacity recommendation. It reads the complete JSON file before validating the row count, so the row limit does not protect a public upload endpoint against an oversized file.
For a real report, decide what the date means: a business date, a reporting period, or the time of a source snapshot. Keep that choice in the input rather than silently using the machine's local date. If the source starts as a CSV import, the Python ETL walkthrough covers a separate validation and loading workflow. This report uses its own JSON fixture and makes no database connection.
The report registers the bundled DejaVu Sans font before adding its first page, because the page header uses that font too. It sets A4 paper, 18 mm side margins, and space below the table for the footer. These calls are an excerpt from the complete script:
pdf = Report(format="A4")
pdf.set_margins(18, 14, 18)
pdf.set_auto_page_break(auto=True, margin=23)
pdf.add_font("Report", fname=FONT)
pdf.alias_nb_pages()
pdf.add_page()
The supplied Report subclass defines the header and footer; FONT points to the font inside the extracted project. Keep the font file beside the code rather than depending on whichever fonts happen to be installed on the host. The fpdf2 Unicode documentation explains registering fonts and using their supported characters.
Our fixture includes Málaga, Łódź, and Zoë. Both PDF text extraction and visual inspection confirmed those names. That check does not establish coverage for every script or emoji. The validator checks the bundled font's character map and rejects missing characters; complex text shaping and right-to-left layout would need their own implementation and tests.
The summary appears above the table, so the reader sees the date, number of orders, and total before the individual rows. Amounts align to the right. A restrained header color and alternating row fills help people follow a row without requiring a decorative chart.
The three columns have separate widths for the order ID, customer name, and amount. The complete script uses this table configuration:
with pdf.table(
col_widths=(29, 108, 37),
line_height=6,
padding=2,
text_align=("LEFT", "LEFT", "RIGHT"),
headings_style=FontFace(
color=(255, 255, 255),
fill_color=(43, 91, 83),
),
repeat_headings="ON_TOP_OF_EVERY_PAGE",
borders_layout="HORIZONTAL_LINES",
cell_fill_color=(245, 248, 245),
cell_fill_mode="ROWS",
) as table:
table.row(("Order ID", "Customer", "Amount (USD)"))
for oid, name, amount in rows:
table.row((oid, name, f"{amount:,.2f}"))
The table documentation covers widths, padding, alignment, and repeated headings. In this sample the library handles the table's page breaks; the script does not guess how many records fit on a sheet. A longer customer name can wrap and consume more height than a short one.
The 40-row fixture fits on two pages. A separate 100-row fixture occupies five. We checked the last row and the headings on continuation pages, along with the page numbers. A dedicated test also verifies that a wrapped customer name retains all its words. Changing the font, page size, or column widths can alter pagination, so rerun those checks after layout edits.
An empty dataset produces a valid one-page report with a zero total and an explicit “No orders” message. Treat that as a meaningful result only if the upstream job actually succeeded. A failed data fetch should not be converted silently into an empty list.
The script validates the input and renders the PDF in memory before touching an existing report. It writes the completed bytes to a temporary file in the output directory, closes that file, and uses os.replace() to replace the destination. See Python's file-replacement reference for the operation's behavior and filesystem constraints.
Running the same report date again replaces the same filename. That is useful for a corrected internal report, but it is a policy choice: if you need to retain every version, add a revision or job identifier and record which result is current. Use separate directories for distinct report owners or datasets so they cannot overwrite one another's daily files.
The local checks confirm that invalid input and simulated rendering or replacement failures leave the previous report intact. They also check temporary-file cleanup after the simulated replacement failure. These checks do not simulate power loss, a killed process, disk failure, or competing writers.
A stable filename is not a promise of byte-identical output. PDF creation metadata can change between runs. The repeat-run test compares extracted content and the output path, not a claim of reproducible binary files. Concurrent jobs need coordination or separate destinations; the sample has no lock and does not provide exactly-once execution.
Install the test dependencies and run the suite from the extracted project directory:
.venv\Scripts\python.exe -m pip install -r requirements-test.txt
.venv\Scripts\python.exe -m unittest -v
The 16 tests cover totals and retained rows, page headings and numbers, Unicode and wrapped text, empty input, stable ordering, reruns, validation failures, and simulated output failures. The test reader is pypdf 7.0.1. Text extraction catches missing values; it cannot prove that two pieces of text do not overlap.
Open every page at a readable zoom, including the last page. Check the table boundary, wrapped names, amount alignment, header and footer separation, and the final record. We also rendered all sample, empty, and long-table pages to images with Poppler and inspected them. The resulting PDF is an example report, with no PDF/A, accessibility, or accounting-compliance certification.
The downloadable project is a local command-line program. If users later request reports through an API, separate accepting the request from delivering the finished file. A possible design records an authorized job, returns its ID, runs the renderer in a worker, and stores the result for an authorized download. This architecture was not implemented or tested here.
Use the FastAPI background-task guide to decide whether small, best-effort work can remain in the API process. For work that needs recovery or separate capacity, the Celery and Redis guide describes a worker setup with its own test limits. Calling this renderer directly is not a test of either integration.
A hosted version needs limits on input bytes and job volume, access checks on both job status and downloads, a retention policy, and a retry strategy. Do not let a submitted filename select an arbitrary server path. Keep customer reports out of a public assets directory, and test that one user cannot retrieve another user's result. A predictable filename provides no authorization.
This text-and-table workload is a candidate for CPU-only compute. The CPU VM buying guide connects that choice to memory, storage, access, and operating responsibilities. Measure representative report sizes and simultaneous jobs before choosing capacity; this local example supplies no Hivenet benchmark or availability claim.
The next useful deployment test is one complete report job on the intended host, followed by a controlled interruption and retry. Keep the generated artifact, input snapshot, dependency versions, and observed result together so a later layout or hosting change can be checked against the same workload.
Pick one AI, compute, or storage workload and see the difference for yourself. Spin it up in minutes, or let our team map your fastest path to production.