Replacing Visual FoxPro reports with Blazor and SQL Server
How to retire FoxPro .frx reports without losing a number: inventory, totals in T-SQL, a Blazor QuickGrid screen, CSV and PDF exports, and parity tests.
By M. Adnan Saleem · · 9 min read
The last thing I retired on the Visual FoxPro migration was the reports. Over a two-and-a-half-year engagement I replaced the legacy FoxPro reports with reporting workflows in the new Blazor application, and only then could the old system be switched off, because reports are what a finance team looks at every month. This is the method: treat each report as a specification, move its arithmetic into T-SQL, give it a Blazor screen and an export, and prove the numbers match before anyone stops running the old one.
Why reports go last, and why they matter most
A report is a view over the data. Migrate it before the tables are in SQL Server and you write it twice. So the order of the migration guide stands: data model first, then the workflows, then the reports that read what the workflows wrote. But do not mistake "last" for "least". Users forgive a slower screen; they do not forgive a month-end total that differs from last month's by a cent, because that cent means they cannot trust any of it.
Inventory: the .frx is a table
A FoxPro report lives in an .frx file, and that file is itself a FoxPro table. Each row is one object on the report, a band, a label, a field or a variable, with the expression it evaluates in the EXPR column and its format in PICTURE. Open it like any other table and you have every calculation the report performs, including the ones nobody remembers adding.
* In Visual FoxPro: a report definition is a table, so read it like one
USE aging.frx ALIAS frx
SELECT expr, picture FROM frx WHERE NOT EMPTY(expr) INTO CURSOR expressions
BROWSE && every expression the report evaluates: fields, totals, calculated columnsTwo more things to collect per report. Who runs it, and when: expect the menu to list more reports than anyone runs, and a month of asking who uses which usually cuts the list hard. The dead ones are not migrated; they are listed in the cutover note so nobody asks for them in a year. And the report's data environment: the SET FILTER, relations and SET DELETED state it runs under, because those are conditions that never appear in the expressions and change the totals.
Totals belong in T-SQL
Most FoxPro reports are a list with subtotals and a grand total. In the .frx that is a group band and report variables with a reset condition; in SQL Server it is one grouped query. Doing the arithmetic in the database means the screen, the CSV export and the parity test all read the same numbers from the same place.
CREATE OR ALTER PROCEDURE dbo.Report_Aging
@AsOf date
AS
BEGIN
SET NOCOUNT ON;
WITH Open AS (
SELECT c.CustomerId, c.Name,
i.Amount - ISNULL(p.Paid, 0) AS Outstanding,
DATEDIFF(day, i.DueOn, @AsOf) AS DaysLate
FROM dbo.Invoice i
JOIN dbo.Customer c ON c.CustomerId = i.CustomerId
OUTER APPLY (SELECT SUM(Amount) AS Paid FROM dbo.Payment WHERE InvoiceId = i.InvoiceId AND PaidOn < DATEADD(day, 1, @AsOf)) p
WHERE i.IssuedOn < DATEADD(day, 1, @AsOf) -- inclusive of @AsOf, like the FoxPro report
AND i.Amount - ISNULL(p.Paid, 0) <> 0
)
SELECT ISNULL(Name, 'Total') AS Customer,
SUM(CASE WHEN DaysLate <= 0 THEN Outstanding ELSE 0 END) AS Current,
SUM(CASE WHEN DaysLate BETWEEN 1 AND 30 THEN Outstanding ELSE 0 END) AS Days1To30,
SUM(CASE WHEN DaysLate BETWEEN 31 AND 60 THEN Outstanding ELSE 0 END) AS Days31To60,
SUM(CASE WHEN DaysLate > 60 THEN Outstanding ELSE 0 END) AS Over60,
SUM(Outstanding) AS Total,
COUNT(*) AS Invoices
FROM Open
GROUP BY ROLLUP (CustomerId, Name)
HAVING GROUPING(CustomerId) = GROUPING(Name) -- one row per customer plus the grand total
ORDER BY GROUPING(CustomerId), Name;
ENDROLLUP produces the per-customer rows and the grand total in one result set, which is the shape the old report printed. The date comparisons are written as < DATEADD(day, 1, @AsOf) rather than <= @AsOf so they stay correct if a column ever becomes a datetime, and so the as-of day is inclusive, which is how FoxPro's date comparisons behaved. Money stays decimal(19,4) all the way through; rounding happens once, at display.
The screen: a Blazor page with QuickGrid
On screen, most reports need a date picker, a table and a download. QuickGrid, Microsoft's grid component for Blazor, does sorting and paging with no JavaScript and no third-party package. The page calls a small IReports interface whose implementation runs the procedure through EF Core, the same seam as in the stored procedure article.
@page "/reports/aging"
@using Microsoft.AspNetCore.Components.QuickGrid
@inject IReports Reports
<h1>Aged receivables</h1>
<form @onsubmit="RunAsync" @onsubmit:preventDefault>
<label>As of <InputDate @bind-Value="asOf" /></label>
<button type="submit" disabled="@busy">Run</button>
<a href="/reports/aging.csv?asOf=@asOf.ToString("yyyy-MM-dd")">Download CSV</a>
</form>
@if (rows.Count == 0 && !busy)
{
<p>No open invoices on that date.</p>
}
else
{
<QuickGrid Items="@rows.AsQueryable()" Pagination="@pagination">
<PropertyColumn Property="@(r => r.Customer)" Sortable="true" />
<PropertyColumn Property="@(r => r.Current)" Format="N2" Align="Align.Right" />
<PropertyColumn Property="@(r => r.Days1To30)" Title="1 to 30" Format="N2" Align="Align.Right" />
<PropertyColumn Property="@(r => r.Days31To60)" Title="31 to 60" Format="N2" Align="Align.Right" />
<PropertyColumn Property="@(r => r.Over60)" Title="Over 60" Format="N2" Align="Align.Right" />
<PropertyColumn Property="@(r => r.Total)" Format="N2" Align="Align.Right" Sortable="true" />
</QuickGrid>
<Paginator State="@pagination" />
}
@code {
private DateOnly asOf = DateOnly.FromDateTime(DateTime.Today);
private IReadOnlyList<AgingRow> rows = [];
private bool busy;
private readonly PaginationState pagination = new() { ItemsPerPage = 50 };
protected override Task OnInitializedAsync() => RunAsync();
private async Task RunAsync()
{
if (busy) return;
busy = true;
try { rows = await Reports.AgingAsync(asOf, CancellationToken.None); }
finally { busy = false; }
}
}The empty state is explicit, because "no open invoices" and "the query failed" must never look alike on a finance screen. The busy flag follows Microsoft's Blazor Server guidance: one database operation per circuit at a time.
Exports: CSV first, PDF when someone needs paper
A CSV endpoint comes before anything else, because a CSV opens in Excel, which is where a finance team continues its work anyway. The endpoint runs the same procedure and needs no library; quoting is the only detail to get right.
app.MapGet("/reports/aging.csv", async (DateOnly asOf, IReports reports, CancellationToken ct) =>
{
var rows = await reports.AgingAsync(asOf, ct);
var sb = new StringBuilder("Customer,Current,1 to 30,31 to 60,Over 60,Total,Invoices\r\n");
foreach (var r in rows)
{
var name = r.Customer.Contains(',') || r.Customer.Contains('"') ? '"' + r.Customer.Replace("\"", "\"\"") + '"' : r.Customer;
sb.Append(name).Append(',').Append(r.Current).Append(',').Append(r.Days1To30).Append(',')
.Append(r.Days31To60).Append(',').Append(r.Over60).Append(',').Append(r.Total).Append(',').Append(r.Invoices).Append("\r\n");
}
return Results.File(Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", $"aging-{asOf:yyyy-MM-dd}.csv");
}).RequireAuthorization("Finance");PDF comes later, and only for the reports that are printed or sent to customers. Pick a PDF library for .NET and read its licence terms for your company size before choosing; several are free for small companies and paid above a revenue line. Resist a report designer. A list with totals, which is what most FoxPro reports turn out to be, does not need one.
Parity: old and new for the same period
The proof is mechanical. Run the FoxPro report for a closed period and export it with COPY TO ... TYPE CSV. Run the new one for the same date. Compare row counts first, then money, exact to the cent. The exports become test fixtures, so the comparison runs on every build, not once on cutover day.
// Fixture: the old report, run in FoxPro for the same date and exported with COPY TO aging-2025-12-31.csv TYPE CSV
public sealed class AgingParityTests(ReportsFixture fx)
{
[Theory]
[InlineData("2025-12-31")]
[InlineData("2026-01-31")]
public async Task New_report_matches_the_FoxPro_export(string date)
{
var asOf = DateOnly.Parse(date);
var expected = FoxProExport.Read($"fixtures/aging-{date}.csv"); // one row per customer plus Total
var actual = await fx.Reports.AgingAsync(asOf, CancellationToken.None);
Assert.Equal(expected.Count, actual.Count); // row count first
foreach (var (e, a) in expected.Zip(actual))
{
Assert.Equal(e.Customer, a.Customer);
Assert.Equal(e.Total, a.Total); // then money, exact to the cent
Assert.Equal(e.Invoices, a.Invoices);
}
}
}When a parity test fails, the cause is almost always one of four things, and this is the order to check them in:
| Symptom | Usual cause | Fix |
|---|---|---|
| Row count differs | Rows flagged deleted in FoxPro, skipped with SET DELETED ON but present in the migrated table | Carry the deleted flag through the migration and exclude it in the query, or pack before export. |
| Totals differ by cents | FoxPro rounded each line to two places before summing; SQL summed four-place values and rounded once | Decide which is right with finance, then match it. Usually the new way, documented as a change. |
| One customer missing or extra | A SET FILTER or relation in the report's data environment | Add the condition to the WHERE clause and to the inventory note. |
| Last day of the period missing | A < where FoxPro had an inclusive comparison | Use < DATEADD(day, 1, @To). |
Cutover, one report at a time
- Parallel run for one month-end. Both reports produced, both compared by the person who signs them off.
- Then the old menu item goes, with a note saying where the report now lives.
- The dead reports are listed in the same note with the last date anyone ran them.
- The parity fixtures stay in the repository. They are the only specification the reports ever had.
Mistakes I would check for first
- Migrating every report. Count who runs what first; most are dead.
- Arithmetic in C# or in the grid. Three places to disagree. Put it in the query.
- Comparing totals before row counts. A missing row and a rounding difference look the same in the total.
- A report designer on day one. Lists with totals need a query and a grid.
- Retiring FoxPro before the parallel month-end. The first discrepancy then has nothing to compare against.
The tables these reports read were loaded and reconciled with control totals in Visual FoxPro to SQL Server: a repeatable data migration; the whole engagement is the FoxPro to .NET case study.
Quick answers
The short version
How do I convert Visual FoxPro reports to .NET?
Inventory the .frx files and who still runs each one, read the report's query and calculated fields as the specification, move the aggregation into a T-SQL query or procedure, render the result in a Blazor page, and prove parity by running old and new for the same period and comparing totals.
What is an .frx file?
A Visual FoxPro report definition. It is itself a FoxPro table: each row is a band, field, line or label, with the expression, position and format in its columns. USE the .frx as a table to read every expression it contains.
Which .NET reporting tool replaces FoxPro reports?
For on-screen reports, a Blazor page with QuickGrid and a T-SQL query is enough. For printed output, choose a PDF library for .NET and check its licence terms for your company size. Most FoxPro reports turn out to be lists with totals that need no report designer at all.
Why do the totals differ between the FoxPro report and the new one?
Usually one of four things: rows FoxPro skipped because SET DELETED was on, rounding done per line in FoxPro and once at the end in SQL, a filter buried in the report's data environment, or a date range that FoxPro treated as inclusive. Compare row counts before comparing sums.
Should reports be migrated before or after the data?
After. A report is a view over the data, so migrating it before the tables are in SQL Server means writing it twice. The same rule holds for the application logic: move the data model first, then the workflows, then the reports that read what the workflows wrote.
Keep reading
Related articles
- Visual FoxPro to SQL Server: a repeatable data migrationMoving Visual FoxPro tables into SQL Server: a mapping for every field type, deleted rows, code pages, and a staging load with control totals you can rehearse.
- Visual FoxPro to .NET and C#: a step-by-step migration guideHow to convert a legacy Visual FoxPro app to C#, .NET and SQL Server when the old code is the only spec: inventory, data, domain rules, reports, cutover.
- Stored procedures in Entity Framework Core: call, map, testWhen a stored procedure belongs in an EF Core app, how to call one with FromSql and ExecuteSql, read output parameters, map saves, and test the data layer.
The project behind this
Read the case study
What I built, where, and the numbers that came out of it.