VBA to C#: A Developer's Translation Guide for Access Modernization
by DeeDee Walsh, on Mar 23, 2026 8:21:26 AM
You know the syntax. You've written the Dim rs As DAO.Recordset a thousand times. You can spot a missing Set statement in your sleep. But now someone's told you the Access app is moving to Blazor, and suddenly you need to think in C#.
Here's the thing: VBA to C# is more than a syntax swap. The languages think differently about types, errors, data access, and memory. A line-by-line translation produces code that compiles but fights the framework at every turn. What you actually need is a semantic translation, one that understands what the VBA is trying to do and expresses that intent in idiomatic C#.
This guide walks through the patterns you'll encounter migrating Access applications to C#/.NET and Blazor. Stuff like constructs that live inside production Access apps: recordset loops, domain aggregates, DoCmd calls, form references, and the error handling spaghetti that keeps it all from falling over.
Variables and Types: The End of Variant
VBA's Variant type is the duct tape of Access development. It holds anything: strings, numbers, dates, nulls, objects, arrays and it figures out what to do at runtime. C# doesn't work that way, and you don't want it to.
VBA:
vb
Dim sName As String Dim iCount As Integer Dim dTotal As Double Dim vResult As Variant Dim bActive As Boolean Dim dtStart As Date
C#:
csharp
string sName; int iCount; // VBA Integer is 16-bit; use int (Int32) in C# double dTotal; object vResult; // Variant → object (but you should strongly type this) bool bActive; DateTime dtStart;
The important translation here isn't the syntax; it's the decision. Every Variant in your VBA code is hiding a type decision someone never made. During modernization, the right move is to examine how each Variant is actually used and assign a proper type. When VELO's Scout Agent analyzes your codebase, it traces data flow through every Variant to determine the intended type. object is the fallback, not the goal.
Watch out for VBA's Null vs. C#'s null:
VBA's Null is a Variant subtype that means "no data". It's distinct from Nothing (no object) and Empty (uninitialized Variant). In C#, null covers all of these, but value types (int, double, DateTime, bool) can't be null unless you make them nullable:
csharp
// VBA: If IsNull(rs!EndDate) Then... // C#: Use nullable types for database fields that allow NULL DateTime? endDate = reader.GetDateTime(ordinal); // nullable if (endDate == null) { /* handle missing date */ } // Or with EF Core model: public DateTime? EndDate { get; set; } // nullable in your entity
Every database column that allows NULLs needs a nullable C# type. Miss this, and you'll get InvalidCastException at runtime on the first record with missing data.
DAO/ADO Recordsets → Entity Framework Core
This is the big one. Access developers live in recordsets. Open a recordset, loop through it, read fields, maybe edit in place, close it, set it to Nothing. It's the core data pattern of every Access app ever written.
Entity Framework Core replaces all of this with LINQ queries and tracked entity objects. The mental model shift is significant: instead of cursoring through rows one at a time, you describe the data you want and EF Core figures out the SQL.
The Basic Read Loop
VBA (DAO):
vb
Dim db As DAO.Database Dim rs As DAO.Recordset Set db = CurrentDb Set rs = db.OpenRecordset( _ "SELECT OrderID, CustomerName, OrderTotal " & _ "FROM tblOrders WHERE OrderDate >= #1/1/2024#", _ dbOpenDynaset) Do While Not rs.EOF Debug.Print rs!CustomerName & ": $" & rs!OrderTotal rs.MoveNext Loop rs.Close Set rs = Nothing Set db = Nothing
C# (EF Core):
// In your service class public async Task<List<Order>> GetRecentOrdersAsync() { var cutoffDate = new DateTime(2024, 1, 1); var orders = await _context.Orders .Where(o => o.OrderDate >= cutoffDate) .Select(o => new { o.OrderId, o.CustomerName, o.OrderTotal }) .ToListAsync(); foreach (var order in orders) { Console.WriteLine($"{order.CustomerName}: ${order.OrderTotal}"); } return orders; }
Notice what disappeared: no connection management, no manual cursor movement, no cleanup. EF Core handles the connection lifecycle through dependency injection. The DbContext is scoped to the request in Blazor Server, so it opens and closes automatically.
FindFirst / Seek → FirstOrDefault
VBA:
vb
rs.FindFirst "CustomerID = 42" If Not rs.NoMatch Then MsgBox "Found: " & rs!CustomerName End If
C#:
csharp
var customer = await _context.Customers .FirstOrDefaultAsync(c => c.CustomerId == 42); if (customer != null) { // Use customer.CustomerName }
Edit/Update in Place → EF Core Change Tracking
VBA:
vb
rs.FindFirst "OrderID = " & lngOrderID If Not rs.NoMatch Then rs.Edit rs!Status = "Shipped" rs!ShipDate = Date rs.Update End If
C#
csharp
var order = await _context.Orders.FindAsync(orderId); if (order != null) { order.Status = "Shipped"; order.ShipDate = DateTime.Today; await _context.SaveChangesAsync(); }
EF Core tracks changes automatically. Modify a property, call SaveChangesAsync(), and it generates the UPDATE statement. No explicit .Edit / .Update ceremony.
AddNew → Add + SaveChanges
VBA:
vb
rs.AddNew rs!CustomerName = txtName.Value rs!Email = txtEmail.Value rs!CreatedDate = Now() rs.Update lngNewID = rs!CustomerID ' Get auto-generated ID
C#
csharp
var customer = new Customer { CustomerName = name, Email = email, CreatedDate = DateTime.Now }; _context.Customers.Add(customer); await _context.SaveChangesAsync(); int newId = customer.CustomerId; // EF Core populates after save
Action Queries → EF Core or Raw SQL
VBA:
vb
CurrentDb.Execute "UPDATE tblOrders SET Status = 'Archived' " & _ "WHERE OrderDate < #1/1/2020#", dbFailOnError
C#:
csharp
// Option 1: EF Core bulk update (.NET 7+) await _context.Orders .Where(o => o.OrderDate < new DateTime(2020, 1, 1)) .ExecuteUpdateAsync(o => o.SetProperty( x => x.Status, "Archived")); // Option 2: Raw SQL for complex operations await _context.Database.ExecuteSqlAsync( $"UPDATE Orders SET Status = 'Archived' WHERE OrderDate < {cutoffDate}");
The parameterized $ string in ExecuteSqlAsync is safe. EF Core converts interpolated values to proper SQL parameters automatically. This is not string concatenation.
Domain Aggregate Functions → LINQ
If you've written Access apps, you've used DLookup, DCount, DSum, DAvg, and DMax everywhere, in form controls, in VBA procedures, in query expressions. They're convenient, but each one fires a separate SQL query. In a form with 20 controls using DLookup, that's 20 round trips to the database every time the form loads.
VBA:
vb
' DLookup - find a single value txtCompanyName = DLookup("CompanyName", "tblCustomers", _ "CustomerID = " & Me.CustomerID) ' DCount - count matching records lblOrderCount.Caption = DCount("*", "tblOrders", _ "CustomerID = " & Me.CustomerID & " AND Status = 'Open'") ' DSum - sum a column txtTotalRevenue = DSum("OrderTotal", "tblOrders", _ "OrderDate >= #1/1/2024# AND OrderDate < #1/1/2025#") ' DMax - get maximum value txtNextInvoice = DMax("InvoiceNumber", "tblInvoices") + 1
C#
csharp
// DLookup → FirstOrDefaultAsync + projection var companyName = await _context.Customers .Where(c => c.CustomerId == customerId) .Select(c => c.CompanyName) .FirstOrDefaultAsync(); // DCount → CountAsync var openOrderCount = await _context.Orders .CountAsync(o => o.CustomerId == customerId && o.Status == "Open"); // DSum → SumAsync var totalRevenue = await _context.Orders .Where(o => o.OrderDate >= new DateTime(2024, 1, 1) && o.OrderDate < new DateTime(2025, 1, 1)) .SumAsync(o => o.OrderTotal); // DMax → MaxAsync var nextInvoice = await _context.Invoices .MaxAsync(i => i.InvoiceNumber) + 1;
The performance win is significant. Instead of scattered DLookup calls that each execute independently, you can batch related queries or use navigation properties to load related data in a single round trip:
csharp
// Load customer with their orders in ONE query var customer = await _context.Customers .Include(c => c.Orders.Where(o => o.Status == "Open")) .FirstOrDefaultAsync(c => c.CustomerId == customerId); // Now access everything without additional queries var name = customer.CompanyName; var openCount = customer.Orders.Count; var total = customer.Orders.Sum(o => o.OrderTotal);
This is where modernization delivers real, measurable performance improvement.
Error Handling: On Error GoTo → try/catch
VBA error handling is one of the most painful patterns to migrate because it's fundamentally different from structured exception handling. The On Error GoTo pattern creates a non-linear flow that's hard to read and harder to maintain.
VBA:
vb
Function GetCustomerBalance(CustomerID As Long) As Currency On Error GoTo ErrHandler Dim db As DAO.Database Dim rs As DAO.Recordset Set db = CurrentDb Set rs = db.OpenRecordset( _ "SELECT Balance FROM tblCustomers WHERE CustomerID = " & CustomerID) If rs.EOF Then GetCustomerBalance = 0 Else GetCustomerBalance = rs!Balance End If CleanUp: If Not rs Is Nothing Then rs.Close Set rs = Nothing End If Set db = Nothing Exit Function ErrHandler: MsgBox "Error " & Err.Number & ": " & Err.Description GetCustomerBalance = 0 Resume CleanUp End Function
C#
csharp
public async Task<decimal> GetCustomerBalanceAsync(int customerId) { try { var balance = await _context.Customers .Where(c => c.CustomerId == customerId) .Select(c => c.Balance) .FirstOrDefaultAsync(); return balance; // returns 0m if not found (default for decimal) } catch (Exception ex) { _logger.LogError(ex, "Failed to retrieve balance for customer {Id}", customerId); return 0m; } }
The CleanUp: label pattern disappears entirely because EF Core manages its own resources, and C#'s using statements and dependency injection handle disposal. The Resume and Resume Next constructs have no direct equivalent and that's a good thing. In C#, you handle the specific exceptions you expect and let unexpected ones propagate up.
The On Error Resume Next problem:
This is the pattern that gives migration engineers nightmares:
vb
On Error Resume Next x = SomeFunction() If Err.Number <> 0 Then x = DefaultValue Err.Clear End If
Every On Error Resume Next block needs manual inspection because it silently swallows errors. The C# equivalent uses targeted try/catch blocks, but the right translation often means fixing the code to not need error suppression in the first place.
DoCmd Equivalents → Blazor Services and Navigation
DoCmd is the Access runtime API. It opens forms, runs queries, sends emails, exports data — all through a single omnibus object. In Blazor, these operations are distributed across services, navigation, and component lifecycle methods.
DoCmd.OpenForm → NavigationManager
VBA:
vb
' Open a form filtered to a specific customer DoCmd.OpenForm "frmOrderDetails", _ WhereCondition:="CustomerID = " & Me.CustomerID, _ OpenArgs:="ReadOnly"
C# (Blazor):
csharp
// In your component code NavigationManager.NavigateTo($"/orders?customerId={customerId}&mode=readonly");
The filter condition becomes a route parameter or query string. The receiving Blazor page reads it during initialization:
csharp
// OrderDetails.razor @page "/orders" [SupplyParameterFromQuery] public int? CustomerId { get; set; } [SupplyParameterFromQuery] public string? Mode { get; set; } protected override async Task OnInitializedAsync() { if (CustomerId.HasValue) { orders = await OrderService.GetByCustomerAsync(CustomerId.Value); } }
DoCmd.RunSQL → Service Method
VBA:
vb
DoCmd.RunSQL "DELETE FROM tblTempResults WHERE SessionID = '" & _ strSessionID & "'"
C#:
csharp
await _cleanupService.ClearTempResultsAsync(sessionId);
Never expose raw SQL through your component layer. Encapsulate data operations in service classes. This is one of the fundamental architecture shifts from Access's "everything talks to the database directly" model.
DoCmd.SendObject → Email Service
VBA:
vb
DoCmd.SendObject acSendReport, "rptInvoice", acFormatPDF, _ strEmail, , , "Your Invoice", "Please find attached.", True
C#:
csharp
await _emailService.SendInvoiceAsync(new InvoiceEmail { To = customerEmail, Subject = "Your Invoice", Body = "Please find attached.", AttachmentStream = await _reportService.GenerateInvoicePdfAsync(invoiceId) });
DoCmd.TransferSpreadsheet → Export Service
VBA:
vb
DoCmd.TransferSpreadsheet acExport, acSpreadsheetTypeExcel12Xml, _ "qryMonthlyReport", "C:\Reports\Monthly.xlsx"
C#:
csharp
var data = await _reportService.GetMonthlyReportAsync(); var excelBytes = _exportService.ToExcel(data); // In Blazor, trigger a file download: await JSRuntime.InvokeVoidAsync("downloadFile", "Monthly.xlsx", excelBytes);
String Functions: The Translation Table
These are straightforward but cause constant compile errors if you guess wrong. Here's the complete mapping:
| VBA | C# | Notes |
|---|---|---|
Left(s, n) |
s[..n] or s.Substring(0, n) |
Range syntax is cleaner in modern C# |
Right(s, n) |
s[^n..] or s.Substring(s.Length - n) |
^n counts from end |
Mid(s, start, len) |
s.Substring(start - 1, len) |
VBA is 1-based, C# is 0-based |
Len(s) |
s.Length |
Property, not function |
InStr(s, find) |
s.IndexOf(find) + 1 |
Returns 0-based; add 1 for VBA parity |
InStr(start, s, find) |
s.IndexOf(find, start - 1) + 1 |
Adjust for 0-based index |
Replace(s, old, new) |
s.Replace(old, new) |
Same semantics |
UCase(s) / LCase(s) |
s.ToUpper() / s.ToLower() |
Method call, not function |
Trim(s) |
s.Trim() |
VBA only trims spaces; C# trims all whitespace |
LTrim(s) / RTrim(s) |
s.TrimStart() / s.TrimEnd() |
Same caveat about whitespace types |
Space(n) |
new string(' ', n) |
|
String(n, c) |
new string(c, n) |
Note: argument order is reversed |
StrComp(a, b, vbTextCompare) |
string.Compare(a, b, StringComparison.OrdinalIgnoreCase) |
|
s & t (concatenation) |
s + t or $"{s}{t}" |
String interpolation is preferred |
vbNullString |
string.Empty or "" |
Functionally equivalent in most contexts |
Nz(value, default) |
value ?? default |
Null-coalescing operator |
The 0-based vs. 1-based trap is the #1 source of off-by-one bugs in manual VBA-to-C# conversions. Every Mid(), InStr(), and array index needs adjustment. VELO's Translation Agent handles this automatically by analyzing the calling context, not just blindly subtracting 1.
Date Functions
VBA:
vb
Dim dtNow As Date dtNow = Now() dtExpiry = DateAdd("m", 3, dtNow) ' Add 3 months lngDays = DateDiff("d", dtStart, dtEnd) ' Days between dates intYear = Year(dtNow) intMonth = Month(dtNow) strFormatted = Format(dtNow, "yyyy-MM-dd")
C#:
csharp
DateTime dtNow = DateTime.Now; DateTime dtExpiry = dtNow.AddMonths(3); int days = (dtEnd - dtStart).Days; // TimeSpan arithmetic int year = dtNow.Year; int month = dtNow.Month; string formatted = dtNow.ToString("yyyy-MM-dd");
Date literals:
VBA uses #1/15/2026#. C# uses new DateTime(2026, 1, 15). The VBA format is locale-dependent (is that January 15 or the 1st of the 15th month?). C#'s constructor is unambiguous.
Collections and Arrays
VBA Collection → C# Generic Collections:
vb
' VBA Dim col As New Collection col.Add "Apple" col.Add "Banana" col.Add "Cherry", "C" ' keyed item For Each item In col Debug.Print item Next
csharp
// C# - List for ordered items var fruits = new List<string> { "Apple", "Banana" }; // C# - Dictionary for keyed items var fruitMap = new Dictionary<string, string> { ["C"] = "Cherry" }; foreach (var fruit in fruits) { Console.WriteLine(fruit); }
VBA arrays are 1-based by default. C# arrays are always 0-based. This affects every For i = LBound(arr) To UBound(arr) loop:
vb
' VBA Dim arr(1 To 5) As String For i = 1 To 5 arr(i) = "Item " & i Next
// C# var arr = new string[5]; for (int i = 0; i < 5; i++) { arr[i] = $"Item {i + 1}"; }
Putting It Together: An Example
Here's a complete Access function you'd find in a production app. It's the kind that calculates an order total with tax, discount, and shipping and its C# equivalent:
VBA:
vb
Public Function CalculateOrderTotal(OrderID As Long) As Currency On Error GoTo ErrHandler Dim db As DAO.Database Dim rs As DAO.Recordset Dim dblSubtotal As Double Dim dblTax As Double Dim dblDiscount As Double Dim dblShipping As Double Dim strState As String Set db = CurrentDb ' Get line item subtotal dblSubtotal = Nz(DSum("Quantity * UnitPrice", "tblOrderLines", _ "OrderID = " & OrderID), 0) ' Get customer state for tax calc strState = Nz(DLookup("State", "tblCustomers", _ "CustomerID = (SELECT CustomerID FROM tblOrders WHERE OrderID = " & _ OrderID & ")"), "") ' Calculate tax If strState = "TX" Then dblTax = dblSubtotal * 0.0825 ElseIf strState = "CA" Then dblTax = dblSubtotal * 0.0725 Else dblTax = 0 End If ' Check for discount If dblSubtotal > 500 Then dblDiscount = dblSubtotal * 0.1 Else dblDiscount = 0 End If ' Flat rate shipping dblShipping = IIf(dblSubtotal > 100, 0, 9.99) CalculateOrderTotal = dblSubtotal - dblDiscount + dblTax + dblShipping CleanUp: Set rs = Nothing Set db = Nothing Exit Function ErrHandler: MsgBox "Error calculating total: " & Err.Description CalculateOrderTotal = 0 Resume CleanUp End Function
C# (Service Class with EF Core):
public class OrderCalculationService { private readonly AppDbContext _context; private readonly ITaxService _taxService; private readonly ILogger<OrderCalculationService> _logger; public OrderCalculationService( AppDbContext context, ITaxService taxService, ILogger<OrderCalculationService> logger) { _context = context; _taxService = taxService; _logger = logger; } public async Task<decimal> CalculateOrderTotalAsync(int orderId) { try { // Load order with line items and customer in ONE query var order = await _context.Orders .Include(o => o.LineItems) .Include(o => o.Customer) .FirstOrDefaultAsync(o => o.OrderId == orderId); if (order == null) return 0m; decimal subtotal = order.LineItems .Sum(li => li.Quantity * li.UnitPrice); decimal tax = _taxService .CalculateTax(subtotal, order.Customer.State); decimal discount = subtotal > 500m ? subtotal * 0.10m : 0m; decimal shipping = subtotal > 100m ? 0m : 9.99m; return subtotal - discount + tax + shipping; } catch (Exception ex) { _logger.LogError(ex, "Failed to calculate total for order {OrderId}", orderId); return 0m; } } }
Look at what changed beyond just syntax. The VBA version fires three separate database queries (two DSum/DLookup calls plus implicit recordset operations). The C# version loads everything in a single query via .Include(). The tax calculation is extracted to a dedicated service instead of being hardcoded. Error handling uses structured logging instead of MsgBox. And the whole thing is async, so it won't block the Blazor Server circuit while waiting for the database.
That's the difference between translation and modernization.
The 70% Wall
If you've been following along and thinking "I could do this with Claude or Codex," you're not wrong...for about 70% of it. General-purpose AI handles syntax translation well. It knows that Dim x As String becomes string x. It can convert a simple recordset loop to LINQ.
But it hits a wall with the patterns that make Access apps unique:
- Binary file parsing: Access
.accdbfiles are binary. You can't paste them into an LLM. VELO's Scout Agent uses deterministic parsing to extract every form, query, table, macro, and module with zero hallucination risk on the extraction step. - Cross-object references: That DLookup on line 47 of
frmOrdersreferences a query that joins three tables, one of which has a data macro that fires on update. Copilot or Claude sees isolated code blocks. VELO's agents maintain a global dependency graph across the entire application. - Architecture decisions: Should this recordset become an EF Core query, a raw SQL call, a stored procedure, or a view? The answer depends on the query's complexity, how it's used across the app, and performance requirements. VELO's Architect Agent makes these decisions systematically, not one snippet at a time.
- Form-to-component mapping: Access forms bind to recordsets with
RecordSource. That single property invokes a cascade of behaviors: current record tracking, dirty state, navigation, filtering that need to be decomposed into Blazor component state, services, and event handlers. That's the "Form Shift" problem, and it requires understanding the full form, not just a code snippet.
If you're migrating a handful of utility functions, the translation table above is all you need. If you're migrating a production Access application with dozens of forms, hundreds of queries, and thousands of lines of VBA, you need a system that understands the whole application at once.
That's what VELO was built for.
Ready to see VELO translate your actual Access code? Start with a free code assessment →
Watch VELO migrate a live Access app to Blazor on our upcoming livestream with Microsoft's Jeff Fritz. Register here →



