Modernize Studio

Equivalence report

Northwind: Access 97 database and its migrated version, compared row by row

Northwind is the sample database Microsoft shipped with Access. We migrated a public copy of it to SQLite and PostgreSQL and ported its saved queries to SQL views. This report is produced by the automated tests that compare the new databases with the original file.

Run started 08/10/2026 02:04:14 (UTC+02:00)Finished 08/10/2026 02:05:43 (UTC+02:00)Duration 88.7 s
All checks passed.

9 of 9 tables, 22 of 22 saved queries (855 runs), 5 of 5 calculations and 17 of 17 constraint tests. Two intentional differences are documented below: exact discount arithmetic and text clean-up.

4,032
rows in 9 tables, all accounted for
22
saved Access queries ported to views
855
query runs compared, full result sets
4
engines checked: SQLite, PostgreSQL, the browser, Access SQL
$1,265,793.02
value of all 2,155 order lines

Source and method

The source is nwind.mdb, the Northwind sample in Access 97 (Jet 3) format, taken from the public test data of the mdbtools project. It is Microsoft's demonstration database: the companies and people in it are fictitious. The file was only read, never opened in Access, and nothing stored in it was executed.

Original file

File
nwind.mdb (Microsoft Northwind sample, Access 97 / Jet 3)
Size
3,002,368 bytes
SHA-256
4682dfc91be526e6508948cc53adf5c63d70fdf7f2cc1f1403ee76b66ac914b2
Unchanged after the run
Yes

Engines and readers

Original, tables
mdbtools 1.0 (independent of the migration) and Jackcess (used by the migration)
Original, Access SQL
UCanAccess 5.1.3 (Ucanaccess 3.x.x), HSQLDB in memory
New, SQLite
SQLite 3.45.1
New, browser
sql.js / SQLite 3.49.1 (sql.js 1.14.2, WebAssembly, Node v22.22.0)
New, PostgreSQL
PostgreSQL 16.15 (Ubuntu 16.15-0ubuntu0.24.04.1), loaded from the delivered scripts in 0.6 s

What is compared

  • Tables. Row counts and a SHA-256 checksum of every row, matched by primary key, between the original file (read with mdbtools) and each new database. Each value is first written in a canonical form: amounts with four decimals, dates as YYYY-MM-DD, pictures as their pixels. The documented text clean-up is applied to the original side, and every value it changed is checked against the migration log. As a cross-check, the two readers of the original file (mdbtools and Jackcess) must agree with each other.
  • Queries. For every saved query, the complete result set (all columns, all rows, duplicates counted) is compared four ways: (1) with an independent model of the Access query written in Python and computed from the original tables; (2) with the Access SQL text executed on the original file by UCanAccess; (3) with the same view in PostgreSQL; (4) with the same view in the browser engine of the web app. Where a query has an ORDER BY, the order of the sort keys is also compared.
  • Calculations. Order totals, invoice figures, sales by category and year, sales by year and quarter and the grand total, compared in the same four ways.
  • Constraints. Statements that break a rule of the original database are sent to the new databases, which must refuse them.

Tables: 9 of 9 identical

Every table of the original file was migrated, including Umsätze, a German-named copy of Orders that we found in the file (same 830 rows, same values). It is kept as a read-only archive table. The 17 pictures stored as OLE objects were converted to PNG; their pixels are compared, and the original bytes are kept in legacy_ole_objects (17 of 17 byte-identical to the file).

Rows: original file / SQLite / PostgreSQL / browser. Checksum: SHA-256 over the sorted row checksums, equal on all four sides when ✓.
New tableAccess tableAccessSQLitePostgreSQLBrowserRows differingChecksumValues cleanedResult
categoriesCategories 8888 055c260ba0358… ✓ –Pass
customersCustomers 91919191 03ca49870a5ae… ✓ 10Pass
employeesEmployees 9999 041560b505f91… ✓ 3Pass
shippersShippers 3333 0657121d69884… ✓ –Pass
suppliersSuppliers 29292929 05a54d4e6b31c… ✓ 11Pass
productsProducts 77777777 0fdbcf701fd18… ✓ –Pass
ordersOrders 830830830830 048c0b261ad43… ✓ 62Pass
order_detailsOrder Details 2,1552,1552,1552,155 0ca6fb9b12ec4… ✓ –Pass
legacy_umsaetzeUmsätze 830830830830 048c0b261ad43… ✓ 62Pass
Total4,0324,0324,0324,0320148

Saved queries: 22 of 22 equivalent

Each of the 22 saved queries is now a view with the same name in snake case, in both SQLite and PostgreSQL. The three parameter queries became views without parameters; the application supplies the values through a WHERE clause, as listed in db/parameterized.sql. Invoices Filter was run for each of the 830 orders. Open a query to see its original SQL and how it was run.

About the Access SQL column. UCanAccess reads the Access 97 discount column as a whole number, so every discount arrives as 0 (see tool findings). To compare like with like, the views are run for this check on a control copy of the new database in which the discounts are also 0. Real discounts are covered by the Python model, PostgreSQL and the browser.

Alphabetical List of Products→ alphabetical_list_of_products · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM alphabetical_list_of_products
Original Access SQL
SELECT DISTINCTROW Products.*, Categories.CategoryName
FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID
WHERE (((Products.Discontinued)=No));
Catalog→ catalog · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM catalog
Original Access SQL
SELECT DISTINCTROW Categories.CategoryName, Categories.Description, Categories.Picture, Products.ProductID, Products.ProductName, Products.QuantityPerUnit, Products.UnitPrice
FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID
WHERE (((Products.Discontinued)=No));
Category Sales for 1995→ category_sales_for_1995 · 1 run · 8 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM category_sales_for_1995
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
A3 A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one).
Discount arithmetic
6 of 8 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW [Product Sales for 1995].CategoryName, Sum([Product Sales for 1995].ProductSales) AS CategorySales
FROM [Product Sales for 1995]
GROUP BY [Product Sales for 1995].CategoryName;
Current Product List→ current_product_list · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM current_product_list
Order (product_name)
Same order of sort keys as Access SQL
Original Access SQL
SELECT [Product List].ProductID, [Product List].ProductName
FROM Products AS [Product List]
WHERE ((([Product List].Discontinued)=No))
ORDER BY [Product List].ProductName;
Customers and Suppliers by City→ customers_and_suppliers_by_city · 1 run · 120 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM customers_and_suppliers_by_city
Order (city, company_name)
Same order of sort keys as Access SQL
Original Access SQL
SELECT City, CompanyName, ContactName, "Customers" AS [Relationship] 
FROM Customers
UNION SELECT City, CompanyName, ContactName, "Suppliers"
FROM Suppliers
ORDER BY City, CompanyName;
Employee Sales by Country→ employee_sales_by_country · 3 runs · 1,299 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM employee_sales_by_country WHERE shipped_date BETWEEN ? AND ?
Parameter values
1995-01-01 .. 1995-12-31; 1994-01-01 .. 1996-12-31; 1995-07-01 .. 1995-09-30
Run on the original with
A2 Parameter query: the PARAMETERS clause or form reference is replaced by literal values, one run per value set. The new view receives the same values through a WHERE clause.
Discount arithmetic
28 of 1,299 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
PARAMETERS Beginning Date DateTime, Ending Date DateTime;
SELECT DISTINCTROW Employees.Country, Employees.LastName, Employees.FirstName, Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal AS SaleAmount
FROM Employees INNER JOIN (Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID) ON Employees.EmployeeID = Orders.EmployeeID
WHERE (((Orders.ShippedDate) Between [Beginning Date] And [Ending Date]));
Invoices→ invoices · 1 run · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM invoices
Discount arithmetic
18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Orders.ShipName, Orders.ShipAddress, Orders.ShipCity, Orders.ShipRegion, Orders.ShipPostalCode, Orders.ShipCountry, Orders.CustomerID, Customers.CompanyName, Customers.Address, Customers.City, Customers.Region, Customers.PostalCode, Customers.Country, [FirstName] & " " & [LastName] AS Salesperson, Orders.OrderID, Orders.OrderDate, Orders.RequiredDate, Orders.ShippedDate, Shippers.CompanyName, [Order Details].ProductID, Products.ProductName, [Order Details].UnitPrice, [Order Details].Quantity, [Order Details].Discount, CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100 AS ExtendedPrice, Orders.Freight
FROM Shippers INNER JOIN (Products INNER JOIN ((Employees INNER JOIN (Customers INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID) ON Employees.EmployeeID = Orders.EmployeeID) INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID) ON Shippers.ShipperID = Orders.ShipVia;
Invoices Filter→ invoices · 830 runs · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM invoices WHERE order_id = ?
Parameter values
830 values (OrderID=10330 ... OrderID=11077)
Run on the original with
A2 Parameter query: the PARAMETERS clause or form reference is replaced by literal values, one run per value set. The new view receives the same values through a WHERE clause.
A3 A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one).
Discount arithmetic
18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Invoices.*
FROM Invoices
WHERE (((Invoices.OrderID)=[Forms]![Orders]![OrderID]));
Order Details Extended→ order_details_extended · 1 run · 2,155 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM order_details_extended
Order (order_id)
Same order of sort keys as Access SQL
Discount arithmetic
18 of 2,155 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW [Order Details].OrderID, [Order Details].ProductID, Products.ProductName, [Order Details].UnitPrice, [Order Details].Quantity, [Order Details].Discount, CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100 AS ExtendedPrice
FROM Products INNER JOIN [Order Details] ON Products.ProductID = [Order Details].ProductID
ORDER BY [Order Details].OrderID;
Order Subtotals→ order_subtotals · 1 run · 830 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM order_subtotals
Discount arithmetic
17 of 830 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW [Order Details].OrderID, Sum(CCur([UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS Subtotal
FROM [Order Details]
GROUP BY [Order Details].OrderID;
Orders Qry→ orders_qry · 1 run · 830 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM orders_qry
Original Access SQL
SELECT DISTINCTROW Orders.OrderID, Orders.CustomerID, Orders.EmployeeID, Orders.OrderDate, Orders.RequiredDate, Orders.ShippedDate, Orders.ShipVia, Orders.Freight, Orders.ShipName, Orders.ShipAddress, Orders.ShipCity, Orders.ShipRegion, Orders.ShipPostalCode, Orders.ShipCountry, Customers.CompanyName, Customers.Address, Customers.City, Customers.Region, Customers.PostalCode, Customers.Country
FROM Customers INNER JOIN Orders ON Customers.CustomerID = Orders.CustomerID;
Product Sales for 1995→ product_sales_for_1995 · 1 run · 77 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM product_sales_for_1995
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
Discount arithmetic
10 of 77 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Categories.CategoryName, Products.ProductName, Sum(CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS ProductSales
FROM (Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID) INNER JOIN (Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID
WHERE (((Orders.ShippedDate) Between #1/1/95# And #12/31/95#))
GROUP BY Categories.CategoryName, Products.ProductName;
Products Above Average Price→ products_above_average_price · 1 run · 25 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM products_above_average_price
Order (unit_price)
Same order of sort keys as Access SQL
Original Access SQL
SELECT DISTINCTROW Products.ProductName, Products.UnitPrice
FROM Products
WHERE (((Products.UnitPrice)>(SELECT AVG([UnitPrice]) From Products)))
ORDER BY Products.UnitPrice DESC;
Products by Category→ products_by_category · 1 run · 69 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM products_by_category
Order (category_name, product_name)
Same order of sort keys as Access SQL
Original Access SQL
SELECT DISTINCTROW Categories.CategoryName, Products.ProductName, Products.QuantityPerUnit, Products.UnitsInStock, Products.Discontinued
FROM Categories INNER JOIN Products ON Categories.CategoryID = Products.CategoryID
WHERE (((Products.Discontinued)<>Yes))
ORDER BY Categories.CategoryName, Products.ProductName;
Quarterly Orders→ quarterly_orders · 1 run · 84 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM quarterly_orders
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
Original Access SQL
SELECT DISTINCTROW Customers.CustomerID, Customers.CompanyName, Customers.City, Customers.Country
FROM Customers RIGHT JOIN Orders ON Customers.CustomerID = Orders.CustomerID
WHERE (((Orders.OrderDate) Between #1/1/95# And #12/31/95#));
Quarterly Orders by Product→ quarterly_orders_by_product · 1 run · 914 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM quarterly_orders_by_product
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
A5 Crosstab: UCanAccess has no TRANSFORM … PIVOT. We run the crosstab's grouping (row headings and pivot value, same aggregate) and pivot the rows into the four fixed column headings.
Discount arithmetic
12 of 914 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
TRANSFORM Sum(CCur([Order Details].[UnitPrice]*[Quantity]*(1-[Discount])/100)*100) AS ProductAmount
SELECT DISTINCTROW Products.ProductName, Orders.CustomerID, Year([OrderDate]) AS OrderYear
FROM Products INNER JOIN (Orders INNER JOIN [Order Details] ON Orders.OrderID = [Order Details].OrderID) ON Products.ProductID = [Order Details].ProductID
WHERE (((Orders.OrderDate) Between #1/1/95# And #12/31/95#))
GROUP BY Products.ProductName, Orders.CustomerID, Year([OrderDate])
PIVOT "Qtr " & DatePart("q",[OrderDate],1,0) In ("Qtr 1","Qtr 2","Qtr 3","Qtr 4");
Sales by Category→ sales_by_category · 1 run · 77 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM sales_by_category
Order (product_name)
Same order of sort keys as Access SQL
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
Discount arithmetic
10 of 77 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Categories.CategoryID, Categories.CategoryName, Products.ProductName, Sum([Order Details Extended].ExtendedPrice) AS ProductSales
FROM Categories INNER JOIN (Products INNER JOIN (Orders INNER JOIN [Order Details Extended] ON Orders.OrderID = [Order Details Extended].OrderID) ON Products.ProductID = [Order Details Extended].ProductID) ON Categories.CategoryID = Products.CategoryID
WHERE (((Orders.OrderDate) Between #1/1/95# And #12/31/95#))
GROUP BY Categories.CategoryID, Categories.CategoryName, Products.ProductName
ORDER BY Products.ProductName;
Sales by Year→ sales_by_year · 3 runs · 1,299 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM sales_by_year WHERE shipped_date BETWEEN ? AND ?
Parameter values
1995-01-01 .. 1995-12-31; 1994-01-01 .. 1996-12-31; 1995-07-01 .. 1995-09-30
Run on the original with
A2 Parameter query: the PARAMETERS clause or form reference is replaced by literal values, one run per value set. The new view receives the same values through a WHERE clause.
Discount arithmetic
28 of 1,299 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
PARAMETERS Forms!Sales by Year Dialog!BeginningDate DateTime, Forms!Sales by Year Dialog!EndingDate DateTime;
SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal, Format([ShippedDate],"yyyy") AS Year
FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID
WHERE (((Orders.ShippedDate) Is Not Null And (Orders.ShippedDate) Between [Forms]![Sales by Year Dialog]![BeginningDate] And [Forms]![Sales by Year Dialog]![EndingDate]));
Sales Totals by Amount→ sales_totals_by_amount · 1 run · 61 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM sales_totals_by_amount
Run on the original with
A1 Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
Discount arithmetic
4 of 61 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW [Order Subtotals].Subtotal AS SaleAmount, Orders.OrderID, Customers.CompanyName, Orders.ShippedDate
FROM Customers INNER JOIN (Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID) ON Customers.CustomerID = Orders.CustomerID
WHERE ((([Order Subtotals].Subtotal)>2500) AND ((Orders.ShippedDate) Between #1/1/95# And #12/31/95#));
Summary of Sales by Quarter→ summary_of_sales_by_quarter · 1 run · 809 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM summary_of_sales_by_quarter
Order (shipped_date)
Same order of sort keys as Access SQL
Discount arithmetic
15 of 809 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal
FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID
WHERE (((Orders.ShippedDate) Is Not Null))
ORDER BY Orders.ShippedDate;
Summary of Sales by Year→ summary_of_sales_by_year · 1 run · 809 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM summary_of_sales_by_year
Order (shipped_date)
Same order of sort keys as Access SQL
Discount arithmetic
15 of 809 rows would differ by one cent or more under Access's floating-point arithmetic (D1); the Access SQL comparison uses the control copy.
Original Access SQL
SELECT DISTINCTROW Orders.ShippedDate, Orders.OrderID, [Order Subtotals].Subtotal
FROM Orders INNER JOIN [Order Subtotals] ON Orders.OrderID = [Order Subtotals].OrderID
WHERE (((Orders.ShippedDate) Is Not Null))
ORDER BY Orders.ShippedDate;
Ten Most Expensive Products→ ten_most_expensive_products · 1 run · 10 rowsPython model ✓ Access SQL ✓ PostgreSQL ✓ Browser ✓
New SQL
SELECT … FROM ten_most_expensive_products
Order (unit_price)
Same order of sort keys as Access SQL
Run on the original with
A4 TOP 10 restored. Access stores it as a query property; the SQL text rebuilt by Jackcess leaves it out, so we read the property from the system table.
A6 DISTINCTROW removed before TOP, which UCanAccess cannot parse. On a single-table query it has no effect in Access.
Original Access SQL
SELECT DISTINCTROW TOP 10 Products.ProductName AS TenMostExpensiveProducts, Products.UnitPrice
FROM Products
ORDER BY Products.UnitPrice DESC;

Calculations: 5 of 5 equal

The figures printed by the Access reports (invoice subtotal, freight and total; sales by category; sales by year and quarter) come from these calculations. The web app reads the same views.

CalculationRowsPython modelAccess SQLPostgreSQLBrowserRows affected by D1Result
Order totals (lines, subtotal after discount, freight, total)830✓✓✓✓17Pass
Invoice report figures (from the Invoices query: subtotal, freight, total per order)830✓✓✓✓17Pass
Sales by category and year (order date)24✓✓✓✓12Pass
Sales by year and quarter (shipped date)8✓✓✓✓7Pass
Grand total of all order lines1✓✓✓✓1Pass

Grand total of the 2,155 order lines: $1,265,793.02 in the new databases, $1,265,792.86 with the original floating-point arithmetic (difference $0.16, explained in D1).

Constraints: 17 of 17 enforced

The validation rules, required fields, field sizes and relationships of the Access file are now constraints in the database itself, so they hold for any program that writes to it, not only for the forms. Each statement below was sent to SQLite and to PostgreSQL inside a transaction that was then rolled back.

AttemptSQLite answerRefused or applied as in Access (SQLite and PostgreSQL)
Order line with quantity 0 (Access rule >0)CHECK constraint failed: quantity > 0✓
Discount above 100% (Access rule Between 0 And 1)CHECK constraint failed: discount BETWEEN 0 AND 1✓
Negative unit price (Access rule >=0)CHECK constraint failed: unit_price >= 0✓
Order for a customer that does not exist (relationship)FOREIGN KEY constraint failed✓
Order line for a product that does not exist (relationship)FOREIGN KEY constraint failed✓
Duplicate product on the same order (primary key)UNIQUE constraint failed: order_details.order_id, order_details.product_id✓
Delete a product that has order lines (relationship)FOREIGN KEY constraint failed✓
Birth date in the future (Access rule <Date())Birth date can't be in the future.✓
Impossible date 31/02 (date type)CHECK constraint failed: order_date IS NULL OR (date(order_date) IS NOT NULL AND date(order_date) = order_date)✓
Product name missing (required)NOT NULL constraint failed: products.product_name✓
Company name longer than 40 characters (field size)CHECK constraint failed: length(company_name) <= 40✓
Price with fractions of a centCHECK constraint failed: unit_price = round(unit_price, 2)✓
Customer code in lower caseCHECK constraint failed: length(customer_id) = 5 AND customer_id = upper(customer_id)✓
Employee reporting to himselfCHECK constraint failed: reports_to IS NULL OR reports_to <> employee_id✓
Writing to the read-only archivelegacy_umsaetze is a read-only archive✓
Deleting an order deletes its 3 lines (Access: cascade delete)✓
Renaming a customer code updates its 6 orders (Access: cascade update)✓

Intentional differences

D1. Discounts are exact percentages; 18 order lines change by one cent

Access evaluates order lines (price × quantity × (1 − discount), rounded to the cent by CCur) in binary floating point. The discount itself is a single-precision number, so 15% is held as 0.15000000596… and 5% as 0.05000000074…, and intermediate amounts such as 9.21375 cannot be held exactly either. On a line whose exact amount falls on a half cent, these tiny errors decide which cent Access shows. The new databases store the discount as the exact percentage and compute every line in whole numbers, with the same round-half-to-even rule that Access applies to an exact half. The result is identical in SQLite, PostgreSQL and the browser.

Compared with our model of the Access arithmetic (double precision, then CCur as implemented by OLE Automation: × 10,000 and round half to even), 18 of 2,155 lines on 17 orders differ by exactly 0.01. The grand total moves from $1,265,792.86 to $1,265,793.02. For lines this close to a half cent the exact Access result depends on the Jet engine's internal order of operations, which cannot be run outside Windows; the new rule removes that dependence.

OrderProductUnit priceQtyDiscountStored by AccessExact amountAccessNew
1032416$13.902115%0.150000005960…248.115$248.11$248.12
1040316$13.902115%0.150000005960…248.115$248.11$248.12
1042963$35.103525%0.25921.375$921.37$921.38
1044016$13.904915%0.150000005960…578.935$578.93$578.94
1054931$12.505515%0.150000005960…584.375$584.37$584.38
1060571$21.50155%0.050000000745…306.375$306.37$306.38
1061638$263.50155%0.050000000745…3754.875$3,754.87$3,754.88
1061671$21.50155%0.050000000745…306.375$306.37$306.38
1064824$4.501515%0.150000005960…57.375$57.37$57.38
1065614$23.25310%0.100000001490…62.775$62.77$62.78
1066864$33.251510%0.100000001490…448.875$448.87$448.88
1072144$19.45505%0.050000000745…923.875$923.87$923.88
1073065$21.05105%0.050000000745…199.975$199.97$199.98
1077645$9.50275%0.050000000745…243.675$243.67$243.68
1080054$7.45710%0.100000001490…46.935$46.93$46.94
1097844$19.45615%0.150000005960…99.195$99.19$99.20
1107016$17.453015%0.150000005960…444.975$444.97$444.98
1107764$33.2523%0.029999999329…64.505$64.51$64.50

D2. Text clean-up (148 values)

  • C1, spaces at the start or end removed (41 values), for example Lino Rodriguez , Frankfurt a.M. , Antonio del Valle Saavedra , Sweden . One of them, Sweden , would otherwise appear as a separate country in any grouping. Double spaces inside a value were left as they are.
  • C2, line breaks stored as LF instead of CR LF (114 values, in two-line addresses and notes). The text is the same; only the end-of-line characters change.

Values per table: customers 10, employees 3, legacy_umsaetze 62, orders 62, suppliers 11. Every change is listed, value by value, in db/migration_log.json. The ship name Galería del gastronómo on orders differs from the customer name Galería del gastrónomo; it is a copy made when the order was entered, so it was not corrected.

Other conversions (no value changed)

  • Dates. Access date/time fields became date. No value in the file has a time part, which the migration verifies.
  • Pictures. Paintbrush bitmaps wrapped in Access OLE objects became PNG files, pixel for pixel; the original objects are archived unchanged.
  • Yes/No fields became booleans (Access stores Yes as −1).
  • Supplier home pages keep the Access hyperlink text (label#address#) unchanged; the application shows the label and the address.
  • Row order. Views keep the ORDER BY of the Access query. SQLite and PostgreSQL sort text by character code here; on this data the order of the sort keys is the same as in the Access SQL run. The web app sorts lists with the browser's language rules.

Tool findings

  • UCanAccess 5.1.3 drops Access 97 discounts. It creates the Discount column (Single) as NUMERIC(100,0) in its in-memory copy, so all 838 non-zero discounts are read as 0. The table cross-check found it: every other column of every table matches. We kept UCanAccess as the engine for the Access SQL text and compared it with a control copy of the new database in which the discounts are 0.
  • The SQL text rebuilt by Jackcess omits TOP 10 in Ten Most Expensive Products. The value is a query property in the system table MSysQueries; we read it from there (A4).
  • UCanAccess could not load nine of the 22 saved queries as views (two-digit years, parameters, the crosstab, duplicate output names). Each was run from its SQL text with the adaptations below. The SQL logic itself was not rewritten.
CodeAdaptation used only to run the original SQL on UCanAccess
A1Two-digit year literals written with four digits (#1/1/95# → #1/1/1995#). UCanAccess rejects the short form; Access reads 95 as 1995.
A2Parameter query: the PARAMETERS clause or form reference is replaced by literal values, one run per value set. The new view receives the same values through a WHERE clause.
A3A saved query that UCanAccess could not load is written inline as a derived table (the SQL text is the saved one).
A4TOP 10 restored. Access stores it as a query property; the SQL text rebuilt by Jackcess leaves it out, so we read the property from the system table.
A5Crosstab: UCanAccess has no TRANSFORM … PIVOT. We run the crosstab's grouping (row headings and pivot value, same aggregate) and pivot the rows into the four fixed column headings.
A6DISTINCTROW removed before TOP, which UCanAccess cannot parse. On a single-table query it has no effect in Access.

Type mapping

Names follow the usual SQL convention (OrderDate → order_date). SQLite tables are declared STRICT, so a column refuses a value of the wrong type.

AccessSQLitePostgreSQLColumns
LONG (AutoNumber)INTEGER PRIMARY KEY (rowid alias, next = max+1)integer GENERATED BY DEFAULT AS IDENTITY7
LONGINTEGERinteger9
INT (16-bit)INTEGER + CHECK rangesmallint4
TEXT(5) keyTEXT + CHECK lengthvarchar(5)3
TEXT(n)TEXT + CHECK(length <= n)varchar(n)48
MEMOTEXTtext3
MONEY (Currency, 4 decimals)REAL (all values have at most 2 decimals; amounts computed in integer cents)numeric(19,4)4
FLOAT (Single, 32-bit)REAL holding the exact decimal fractionnumeric(5,4)1
DATETIME (no time part in any row)TEXT 'YYYY-MM-DD' + CHECK date(x)=xdate8
BOOLEAN (Yes=-1/No=0)INTEGER 0/1 + CHECKboolean1
OLE Object (Paintbrush bitmap)BLOB (PNG)bytea (PNG)2
All 90 columns, one by one
Access columnNew columnAccess typePostgreSQL typeRules
Categories.CategoryIDcategories.category_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Categories.CategoryNamecategories.category_nameTEXT(15)varchar(15)required
Categories.Descriptioncategories.descriptionMEMOtext
Categories.Picturecategories.pictureOLE Object (Paintbrush bitmap)bytea (PNG)
Customers.CustomerIDcustomers.customer_idTEXT(5) keyvarchar(5)required; primary key; New rule from the field description ("unique five-character code"); Access compares text case-insensitively, so codes are kept upper-case to keep them unique in the same way. All 91 rows comply.
Customers.CompanyNamecustomers.company_nameTEXT(40)varchar(40)required
Customers.ContactNamecustomers.contact_nameTEXT(30)varchar(30)
Customers.ContactTitlecustomers.contact_titleTEXT(30)varchar(30)
Customers.Addresscustomers.addressTEXT(60)varchar(60)
Customers.Citycustomers.cityTEXT(15)varchar(15)
Customers.Regioncustomers.regionTEXT(15)varchar(15)
Customers.PostalCodecustomers.postal_codeTEXT(10)varchar(10)
Customers.Countrycustomers.countryTEXT(15)varchar(15)
Customers.Phonecustomers.phoneTEXT(24)varchar(24)
Customers.Faxcustomers.faxTEXT(24)varchar(24)
Employees.EmployeeIDemployees.employee_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Employees.LastNameemployees.last_nameTEXT(20)varchar(20)required
Employees.FirstNameemployees.first_nameTEXT(10)varchar(10)required
Employees.Titleemployees.titleTEXT(30)varchar(30)
Employees.TitleOfCourtesyemployees.title_of_courtesyTEXT(25)varchar(25)Access lookup value list Dr.;Mr.;Miss;Mrs.;Ms. (not enforced by Access); all 9 rows comply
Employees.BirthDateemployees.birth_dateDATETIME (no time part in any row)dateAccess validation rule <Date() ("Birth date can't be in the future.") -> trigger, because CHECK cannot use the current date
Employees.HireDateemployees.hire_dateDATETIME (no time part in any row)date
Employees.Addressemployees.addressTEXT(60)varchar(60)
Employees.Cityemployees.cityTEXT(15)varchar(15)
Employees.Regionemployees.regionTEXT(15)varchar(15)
Employees.PostalCodeemployees.postal_codeTEXT(10)varchar(10)
Employees.Countryemployees.countryTEXT(15)varchar(15)
Employees.HomePhoneemployees.home_phoneTEXT(24)varchar(24)
Employees.Extensionemployees.extensionTEXT(4)varchar(4)
Employees.Photoemployees.photoOLE Object (Paintbrush bitmap)bytea (PNG)
Employees.Notesemployees.notesMEMOtext
Employees.ReportsToemployees.reports_toLONGinteger→ employees; New foreign key: Access had only a lookup, no relationship. All 9 rows comply.
Shippers.ShipperIDshippers.shipper_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Shippers.CompanyNameshippers.company_nameTEXT(40)varchar(40)required
Shippers.Phoneshippers.phoneTEXT(24)varchar(24)
Suppliers.SupplierIDsuppliers.supplier_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Suppliers.CompanyNamesuppliers.company_nameTEXT(40)varchar(40)required
Suppliers.ContactNamesuppliers.contact_nameTEXT(30)varchar(30)
Suppliers.ContactTitlesuppliers.contact_titleTEXT(30)varchar(30)
Suppliers.Addresssuppliers.addressTEXT(60)varchar(60)
Suppliers.Citysuppliers.cityTEXT(15)varchar(15)
Suppliers.Regionsuppliers.regionTEXT(15)varchar(15)
Suppliers.PostalCodesuppliers.postal_codeTEXT(10)varchar(10)
Suppliers.Countrysuppliers.countryTEXT(15)varchar(15)
Suppliers.Phonesuppliers.phoneTEXT(24)varchar(24)
Suppliers.Faxsuppliers.faxTEXT(24)varchar(24)
Suppliers.HomePagesuppliers.home_pageMEMOtext
Products.ProductIDproducts.product_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Products.ProductNameproducts.product_nameTEXT(40)varchar(40)required
Products.SupplierIDproducts.supplier_idLONGinteger→ suppliers
Products.CategoryIDproducts.category_idLONGinteger→ categories
Products.QuantityPerUnitproducts.quantity_per_unitTEXT(20)varchar(20)
Products.UnitPriceproducts.unit_priceMONEY (Currency, 4 decimals)numeric(19,4)Access validation rule >=0 ("You must enter a positive number.")
Products.UnitsInStockproducts.units_in_stockINT (16-bit)smallintAccess validation rule >=0 ("You must enter a positive number.")
Products.UnitsOnOrderproducts.units_on_orderINT (16-bit)smallintAccess validation rule >=0 ("You must enter a positive number.")
Products.ReorderLevelproducts.reorder_levelINT (16-bit)smallintAccess validation rule >=0 ("You must enter a positive number.")
Products.Discontinuedproducts.discontinuedBOOLEAN (Yes=-1/No=0)booleanrequired
Orders.OrderIDorders.order_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Orders.CustomerIDorders.customer_idTEXT(5) keyvarchar(5)→ customers
Orders.EmployeeIDorders.employee_idLONGinteger→ employees
Orders.OrderDateorders.order_dateDATETIME (no time part in any row)date
Orders.RequiredDateorders.required_dateDATETIME (no time part in any row)date
Orders.ShippedDateorders.shipped_dateDATETIME (no time part in any row)date
Orders.ShipViaorders.ship_viaLONGinteger→ shippers
Orders.Freightorders.freightMONEY (Currency, 4 decimals)numeric(19,4)New rule (no negative freight); all 830 rows comply
Orders.ShipNameorders.ship_nameTEXT(40)varchar(40)
Orders.ShipAddressorders.ship_addressTEXT(60)varchar(60)
Orders.ShipCityorders.ship_cityTEXT(15)varchar(15)
Orders.ShipRegionorders.ship_regionTEXT(15)varchar(15)
Orders.ShipPostalCodeorders.ship_postal_codeTEXT(10)varchar(10)
Orders.ShipCountryorders.ship_countryTEXT(15)varchar(15)
Order Details.OrderIDorder_details.order_idLONGintegerrequired; primary key; → orders
Order Details.ProductIDorder_details.product_idLONGintegerrequired; primary key; → products
Order Details.UnitPriceorder_details.unit_priceMONEY (Currency, 4 decimals)numeric(19,4)required; Access validation rule >=0 ("You must enter a positive number.")
Order Details.Quantityorder_details.quantityINT (16-bit)smallintrequired; Access validation rule >0 ("Quantity must be greater than 0")
Order Details.Discountorder_details.discountFLOAT (Single, 32-bit)numeric(5,4)required; Access validation rule Between 0 And 1
Umsätze.OrderIDlegacy_umsaetze.order_idLONG (AutoNumber)integer GENERATED BY DEFAULT AS IDENTITYrequired; primary key
Umsätze.CustomerIDlegacy_umsaetze.customer_idTEXT(5) keyvarchar(5)
Umsätze.EmployeeIDlegacy_umsaetze.employee_idLONGinteger
Umsätze.OrderDatelegacy_umsaetze.order_dateDATETIME (no time part in any row)date
Umsätze.RequiredDatelegacy_umsaetze.required_dateDATETIME (no time part in any row)date
Umsätze.ShippedDatelegacy_umsaetze.shipped_dateDATETIME (no time part in any row)date
Umsätze.ShipVialegacy_umsaetze.ship_viaLONGinteger
Umsätze.Freightlegacy_umsaetze.freightMONEY (Currency, 4 decimals)numeric(19,4)
Umsätze.ShipNamelegacy_umsaetze.ship_nameTEXT(40)varchar(40)
Umsätze.ShipAddresslegacy_umsaetze.ship_addressTEXT(60)varchar(60)
Umsätze.ShipCitylegacy_umsaetze.ship_cityTEXT(15)varchar(15)
Umsätze.ShipRegionlegacy_umsaetze.ship_regionTEXT(15)varchar(15)
Umsätze.ShipPostalCodelegacy_umsaetze.ship_postal_codeTEXT(10)varchar(10)
Umsätze.ShipCountrylegacy_umsaetze.ship_countryTEXT(15)varchar(15)

Reproduce

Everything above is produced by scripts; nothing in this report is typed by hand. From the case-study folder:

python3 migration/build_db.py      # migrate: db/northwind.sqlite, PostgreSQL scripts, migration log
python3 migration/equivalence.py   # all comparisons (about 89 s)
python3 migration/make_report.py   # this page and results.json

Delivered files: db/schema.sqlite.sql, db/views.sqlite.sql, db/schema.postgresql.sql, db/data.postgresql.sql, db/views.postgresql.sql, db/parameterized.sql, db/northwind.sqlite (SHA-256 525cdfd6a4eb016f…, 1,142,784 bytes).