Playbook ~10 min read By The Compiler Whisperer · October 2026

Migrating a VB6 Report to a Modern Stack

Every legacy system I have retired has had one component that survives longer than everything else: the reports. The front end gets replaced. The database gets moved. The batch jobs get rewritten. But the Tuesday morning invoice register, the month-end aging summary, and the printout that the warehouse posts on the wall — those outlive the application that produced them, because the report is the thing the business actually reads.

That is also why migrating a report is a different problem from migrating an application. An application has a specification — a bad one, maybe, but one exists. A report's specification is its output: the columns, the order, the subtotals, the font that is somehow 7.5pt, the margin that is exactly 14mm because that is what fit under the old dot-matrix printer. The spec is not in the code. The spec is in the eyes of the person who reads it every week.

This article is the playbook for moving that output from a VB6 DataReport (or a Crystal Report, or a hand-rolled form painted with Print statements) onto a modern stack — SQL in a managed database, Python for the pipeline, PDF/HTML/Excel for the output — without losing a single line the business depends on.

First: where your report actually lives

In a 1990s–2000s VB6 shop, a "report" usually means one of four physical things. Find out which one before you plan anything, because the migration path differs for each:

KindWhat you will findWhat is hard
VB6 DataReport (Data Environment).rd files, a de_ object in code, report bound to a stored proc or queryThe layout lives in a binary .rd file — you cannot diff it, and it was rarely in source control
Crystal Reports.rpt files, a Crystal engine in the EXE or serverThe .rpt file is the spec. It renders in the designer; extract logic from there
Form-as-reportA plain VB6 form with text boxes, printed via CommonDialog.PrintLogic and layout are braided together in one .frm file
Excel-dumpADO + OLE automation writing a .xls, maybe with a macro formatting itThe formatting macro is the report; the database query is the easy half

The honest first step is an inventory, because in these shops the report count is always a third higher than anyone remembers:

Step 1 — Capture the contract before you can break it

A report has no unit tests, because its test is its output. So before writing any new code, freeze the current behavior:

  1. Run each report at least three times with different data states — a normal month, a month with a large one-off, an empty/edge month (zero invoices, one customer with a negative balance). Save every output verbatim. These files are your acceptance tests.
  2. Record the parameters. Date range, branch, currency, "include on-hold orders" — whatever the report prompt asks. Write them down; in six weeks no one will remember that the second parameter was fiscal month, not calendar month.
  3. Record the destination and the reader. Printed on the Zebra in the shipping bay. Emailed to the CFO. Copied to the folder the accountant opens. A report with no reader is a candidate for deletion, not migration.
  4. Pin a data snapshot. If you can, take a full copy of the source database at a known date. The new pipeline will be tested against this snapshot, comparing output to the saved samples, so that "the data changed" is never an excuse for "the numbers changed".

This step feels bureaucratic until the day the new invoice register comes out "almost identical" and the accountant finds a 0.03 rounding difference in one sub-column. That difference came from a rounding rule that existed only in the old report's code. If you have the captured samples, you find it in an hour. If you don't, you find it at the bank.

Step 2 — Re-implement in two layers, not one

The old stack did three jobs in one process: fetch (ADO query or stored proc), shape (VB6 code computing subtotals, running balances, group headers), and render (the .rd layout or the print stream). Split those three jobs into two layers and you get a report you can actually maintain:

LAYER 1 — DATA (SQL in the managed database) raw query or stored proc → deterministic rows + computed fields (running totals, group keys) → output: a flat, fully-computed result set. No layout logic here. LAYER 2 — PRESENTATION (Python, one small service) receives the flat result set → renders PDF (WeasyPrint), HTML (Jinja2 + print CSS), or Excel (openpyxl/XlsxWriter) → writes to the agreed destination (folder, email, S3) WHY TWO LAYERS The data layer is testable against the database — no pixels involved. The presentation layer is testable against your captured samples — no SQL involved. When the accountant says "the total is wrong", you already know which layer to open.

Layer 1 deserves a sentence of respect: in a VB6 report, the real business logic — the running balance across payment terms, the "on-hold but not past-due" split, the tax split by branch — is usually tangled between the stored proc, the recordset loop, and the report bindings. Untangling it is the project. Put it all in the database, in a view or stored proc you can run by hand, and you can verify the numbers without rendering anything at all.

Step 3 — Pick the output format by the reader, not by the technology

Reader / destinationChooseWhy
Printed, by a human, daily or weeklyPDF (WeasyPrint + print CSS)Pagination, headers/footers, "Page 2 of 7" — CSS handles it; the printer gets a stable format
Opened in Excel by an analystXLSX (openpyxl or XlsxWriter)Keep the columns and sort order they expect. Do not add charts; they didn't have charts before, and they will diff the sheet
Read in a browser or on a phoneHTML (Jinja2 template)A table with decent CSS beats a PDF on a phone; this is also the cheapest option
Consumed by another systemCSV/JSON at a fixed path or URLNo rendering at all — the data layer's output is the product

Do not migrate to PDF "because it is modern" if the reader opens Excel. The output format is part of the contract you captured in Step 1. Changing it is a feature change, and feature changes need the same sign-off as a business rule change — because to the reader, that is exactly what it is.

Step 4 — Verify line by line, then run one cycle in parallel

With both layers in place, verification has a clean structure:

Step 5 — Where the new pipeline lives

The new stack is small: a query in the database, a Python service a few hundred lines long, a renderer. You do not need a cluster. You need a host that receives patches, backs up nightly, and does not live in someone's corner office. For a team that already runs the database in the cloud, the honest setup is:

Risks that bite in report migrations, and the cheap mitigations

RiskHow it shows upCheap mitigation
Hidden rounding / truncationTotals off by cents; sub-totals not matching the grand totalPin the snapshot; compare computed fields at the data layer, not the rendered page
Lost parameter semantics"Month" was fiscal month; new report uses calendar monthRecord every parameter's meaning in Step 1; ask the reader, not the code
Reader-specific columnsA column exists only because one person in 1998 asked for it — and still opens the report to look at itInventory outputs with the readers; keep every column until you've seen one full cycle of the new version
Frequency blind spotThe quarterly report breaks and nobody notices for 90 daysCapture its sample before migrating it; if you can't run it this quarter, don't migrate it this quarter
The "it looked fine" trapOld report was always slightly wrong and the business had adjusted to itDiff against captured outputs, not against expectations; agree with the reader whether the old behavior was the spec

FAQ

How long does a single report take to migrate?

For a report that is really a query plus a layout: days of data-layer work, days of presentation, and one real cycle of parallel running. The wall-clock time is dominated by the parallel cycle, not the code. A suite of fifteen reports is not fifteen times one report — they usually share data-layer work, which is where the real cost lives.

Can I keep the database as-is and just replace the report engine?

Yes, and for many teams that is the right first step: the data layer is still "the old database", but now it is a managed, backed-up database with a view instead of a DataReport bound to it. Moving the database itself is a separate, later decision. Do the report migration first; it will tell you what the database actually needs to be.

What if the report's logic depends on a stored proc no one understands?

Then the stored proc is the business logic, and you have a rewrite problem wearing a report's costume. Capture its outputs on a pinned snapshot, then re-derive the logic from the data and the samples. If the stored proc is 3,000 lines, expect the data-layer work to dominate the whole migration — that is the honest estimate, and it is still cheaper than a full application rewrite, because the scope is one output, not the whole system.

Do I need to match the old layout exactly?

Match the information: same columns, same order, same subtotals, same rounding. Do not burn a week matching fonts and margins — a cleaner layout with identical numbers is a win, and the reader will tell you fast if they cared about the margins more than the numbers.

The only person who can run the old report is leaving. What do I do this week?

Step 1, now: capture the outputs, the parameters, and the destinations while it still runs. Everything else can wait a week; the capture cannot. If you want the full picture of this situation — the report is one symptom of a deeper problem — see The "Last Admin" Problem.

Summary

A legacy report is a specification that was never written down — it was rendered, every week, by a program no one reads anymore. Migrate it by treating the output as the contract: capture it on pinned data, split the logic into a data layer (SQL) and a presentation layer (Python), render in the format the reader actually uses, verify against the captured files, run one real cycle in parallel, and keep the old thing warm for 90 days. It is not glamorous work. It is the work that lets you delete the VB6 machine without the accountant finding out on the 14th.

Where to go next

Disclosure: some links above are affiliate links (marked where they appear). They never cost you more, and they never influence the recommendation. Full details on the affiliate disclosure page. This article is general engineering information, not professional advice.