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:
| Kind | What you will find | What is hard |
|---|---|---|
| VB6 DataReport (Data Environment) | .rd files, a de_ object in code, report bound to a stored proc or query | The 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 server | The .rpt file is the spec. It renders in the designer; extract logic from there |
| Form-as-report | A plain VB6 form with text boxes, printed via CommonDialog.Print | Logic and layout are braided together in one .frm file |
| Excel-dump | ADO + OLE automation writing a .xls, maybe with a macro formatting it | The 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:
- Grep the source for report artifacts.
de_object names,Report,.rd,Crystal,OLE,Excel.Application. Each hit is a candidate. - List every output file the app writes. Process Monitor for a day in production will show the real .pdf/.xls/.txt/.csv names. Names like
C:\temp\INV20030715.XLSare the true inventory. - Ask the readers, not the devs. "Which reports do you actually read, and where do they end up — printer, shared folder, email, a bank portal?" A report nobody reads can be retired with its whole dependency tree.
- Note the cadence. Daily register, week-end stockout, month-end aging, quarterly export. Cadence tells you which ones you can test over a real cycle and which ones you will only see once a quarter — those need a captured sample now.
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:
- 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.
- 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.
- 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.
- 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 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 / destination | Choose | Why |
|---|---|---|
| Printed, by a human, daily or weekly | PDF (WeasyPrint + print CSS) | Pagination, headers/footers, "Page 2 of 7" — CSS handles it; the printer gets a stable format |
| Opened in Excel by an analyst | XLSX (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 phone | HTML (Jinja2 template) | A table with decent CSS beats a PDF on a phone; this is also the cheapest option |
| Consumed by another system | CSV/JSON at a fixed path or URL | No 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:
- Data layer vs. captured numbers. Run the new query against the pinned snapshot. Compare computed fields to the totals in the saved sample reports. Mismatch = data layer bug. You will fix these with SQL, not with your eyes.
- Presentation layer vs. captured files. Render the new output for the same snapshot. Lay it side by side with the saved PDF/XLSX. Same columns, same order, same subtotals, same rounding. The font does not need to match — the numbers and their arrangement do.
- One full cycle in parallel. For a month-end report, run old and new through the entire real month, both producing their outputs to their real destinations. The business reads the old one (it is still the system of record). You diff quietly. The first cycle catches something — that is what it is for.
- Cut over at the boundary, keep the old read-only for 90 days. The old VB6 machine does not get decommissioned at cutover. It gets switched off, kept warm, kept backed up, and available for "just one more look". That safety net has cost me nothing and saved me twice.
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:
- Managed database (PostgreSQL or SQL Server, depending on where the data already lives) — the report's data layer is a view in here.
- One small app server running the Python service, on a managed host. If you want it simple, a Cloudways-managed instance ↗ running the app next to the database gets you patching, backups, and monitoring without owning the box. Affiliate link — I may earn a commission if you sign up; you pay the same price either way.
- A schedule. Cron (or a task scheduler on the host) triggers the pipeline at the same cadence the old report ran. The readers should not notice that anything moved except that it now arrives a few seconds earlier.
Risks that bite in report migrations, and the cheap mitigations
| Risk | How it shows up | Cheap mitigation |
|---|---|---|
| Hidden rounding / truncation | Totals off by cents; sub-totals not matching the grand total | Pin the snapshot; compare computed fields at the data layer, not the rendered page |
| Lost parameter semantics | "Month" was fiscal month; new report uses calendar month | Record every parameter's meaning in Step 1; ask the reader, not the code |
| Reader-specific columns | A column exists only because one person in 1998 asked for it — and still opens the report to look at it | Inventory outputs with the readers; keep every column until you've seen one full cycle of the new version |
| Frequency blind spot | The quarterly report breaks and nobody notices for 90 days | Capture its sample before migrating it; if you can't run it this quarter, don't migrate it this quarter |
| The "it looked fine" trap | Old report was always slightly wrong and the business had adjusted to it | Diff 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
- Deciding whether to contain, wrap, rewrite, or replace the whole VB6 app? → Retiring a VB6/VBA Application: The 2026 Playbook
- Your report sits on top of an Access database? → Microsoft Access Alternatives: A 2026 Comparison
- Not sure what the old system is really costing you? → The Hidden Cost of Keeping a Legacy System Alive
- Only one person understands the whole thing? → The "Last Admin" Problem