Data Access with ADO.NET and EF Core

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. Every example that talks to a database and prints results is a complete program that was built with warnings treated as errors (Option Strict On, root namespace ShelfApp) and run in the .NET 10 SDK container on Linux. Databases that need a server (SQL Server, MongoDB, Redis) cannot run in that container, so the SQL Server code is labelled built, not run; the parts of the SQL Server provider that do not need a server (connection string defaults, the error for an unreachable server) were run. SQLite, which is an in-process engine, stands in for the server wherever a real round trip is needed: Microsoft.Data.Sqlite for ADO.NET and the Microsoft.EntityFrameworkCore.Sqlite provider for EF Core. Package versions used: Microsoft.Data.Sqlite 10.0.13, Microsoft.Data.SqlClient 7.1.1, Microsoft.EntityFrameworkCore.Sqlite 10.0.13, Dapper 2.1.89.

Data access is where Visual Basic differs from C# in several small ways that all come from the same source: the language has no nullable reference types and no Await Using, and the tooling around it (the EF Core command line, the designers) was built for C# first. This page shows what works as in C#, what needs a different spelling, and what does not work at all. The reference scenario is the shelf catalogue; the entities are Book, Author and the others from Shelf.Core.

SQL itself, indexes and schema design are not repeated here: see the SQL reference and the Databases section. EF Core concepts are explained for C# on Data Access with EF Core in the ASP.NET Core section, and the older EF6 on Data Access with EF6; this page covers the Visual Basic spelling and the differences.

Which data-access API?

flowchart TD Start["What are you talking to?"] --> Kind{"Relational database?"} Kind -- "No: documents, keys, caches" --> Nosql["The store's own driver MongoDB.Driver, StackExchange.Redis"] Kind -- "Yes" --> Legacy{"Existing .NET Framework app already on EF6 or LINQ to SQL?"} Legacy -- "Yes, and it stays on .NET Framework" --> Ef6["Keep EF6 or LINQ to SQL plan the move to EF Core"] Legacy -- "No" --> Model{"Do you want a mapped object model, change tracking and LINQ?"} Model -- "Yes" --> Ef["EF Core query syntax and method syntax work in VB"] Model -- "No, I write the SQL" --> Repeat{"Many queries that map rows to the same classes?"} Repeat -- "Yes" --> Dapper["Dapper (community) on top of ADO.NET"] Repeat -- "No, or I need full control" --> Ado["ADO.NET provider directly Microsoft.Data.SqlClient or Microsoft.Data.Sqlite"] Ef --> Mig{"Migrations?"} Mig -- "Yes" --> Cs["Put migrations in a small C# project VB scaffolding is not supported by dotnet ef"]
API Choose it when Visual Basic notes

ADO.NET provider (Microsoft.Data.SqlClient, Microsoft.Data.Sqlite, Npgsql, …​)

You write SQL, need every provider feature (bulk copy, table-valued parameters, output parameters), or you are building the layer that other tools sit on.

Everything works. Use Using for every disposable; no Await Using.

Dapper

You write SQL but do not want to write the reader loop. It is a small library that adds extension methods to IDbConnection.

Works; anonymous types are the usual way to pass parameters. A community project, not a Microsoft one.

EF Core

You want a model, change tracking, LINQ and migrations.

Works. Migrations must be scaffolded from a C# project; query syntax such as Group By and Aggregate is translated to SQL.

EF6 and LINQ to SQL

You maintain an existing .NET Framework application.

Legacy. Not for new development; see the last sections.

Stored procedures

The logic lives in the database.

Call them through ADO.NET (CommandType.StoredProcedure) or EF Core’s FromSql.

Project setup

Every ADO.NET and EF Core example in this page is a console project of this shape (the Dapper, caching and configuration examples add one package each). The Directory.Build.props turns Option Strict On and warnings-as-errors on for the whole solution, as the other pages do; NuGetAudit is off only so that the build output of these scratch projects is deterministic (see the note on advisories below).

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <OptionExplicit>On</OptionExplicit>
    <OptionStrict>On</OptionStrict>
    <OptionInfer>On</OptionInfer>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <NuGetAudit>false</NuGetAudit>
  </PropertyGroup>
</Project>
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <RootNamespace>ShelfApp</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.13" />
    <PackageReference Include="Dapper" Version="2.1.89" />
    <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.13" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.13" />
    <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.13" />
    <PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.13" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>

The programs in the next sections are Module`s in one project, each with its own `Sub Main; the project property StartupObject (here set on the build command line) chooses which one runs. In your own project there is one Sub Main.

A note on package advisories. With Microsoft.EntityFrameworkCore.Sqlite 10.0.0 the restore reported NU1903 (a known high-severity advisory) for the native SQLite package that it pulls in (SQLitePCLRaw.lib.e_sqlite3 2.1.11). The examples here use 10.0.13 and switch auditing off only to keep their output reproducible; in a real project leave NuGetAudit on, treat NU1903 as a prompt to update the package, and combine it with TreatWarningsAsErrors deliberately.

ADO.NET in one page

ADO.NET is a set of abstract classes in System.Data.Common that every provider implements:

Abstract type SQL Server (Microsoft.Data.SqlClient) SQLite (Microsoft.Data.Sqlite)

DbConnection

SqlConnection

SqliteConnection

DbCommand

SqlCommand

SqliteCommand

DbParameter

SqlParameter

SqliteParameter

DbDataReader

SqlDataReader

SqliteDataReader

DbTransaction

SqlTransaction

SqliteTransaction

DbDataAdapter

SqlDataAdapter

none (the assembly exports no type that derives from DbDataAdapter; see the check below)

DbConnectionStringBuilder

SqlConnectionStringBuilder

SqliteConnectionStringBuilder

DbException

SqlException (with Number, Class, State)

SqliteException (with SqliteErrorCode, SqliteExtendedErrorCode)

The provider you pick is a NuGet package. For SQL Server use Microsoft.Data.SqlClient, which replaces System.Data.SqlClient (the old package is the one that older books and answers show; the replacement keeps the names, so Imports System.Data.SqlClient becomes Imports Microsoft.Data.SqlClient, and the new provider encrypts by default; see below). System.Data.OracleClient no longer exists; Oracle’s own managed provider is Oracle.ManagedDataAccess.Core. Npgsql (PostgreSQL) and MySqlConnector (MySQL, MariaDB) are community providers with the same shape. The abstract types let a repository be written once against DbConnection and DbCommand, and DbProviderFactory creates the concrete ones when the provider is chosen at run time.

Connections, pooling and connection strings

A connection is expensive to open and cheap to keep in a pool. The rule that follows is the same in every language: open late, close early, and let the pool do the sharing. In Visual Basic that means a Using block around every SqlConnection or SqliteConnection; the Dispose at End Using returns the physical connection to the pool, it does not close the socket.

Pooling defaults, and a failed connect, without a server

Two things about SQL Server connections can be checked without a server. The first is what the connection-string builder assumes when you say nothing; the second is what a failed connection looks like. This program was run (a loopback port with nothing listening gives an immediate refusal, so no network is needed):

Option Strict On

Imports Microsoft.Data.SqlClient

Module Offline
    Sub Main()
        Dim builder As New SqlConnectionStringBuilder()
        Console.WriteLine("Defaults of SqlConnectionStringBuilder:")
        Console.WriteLine("  Encrypt             = " & builder.Encrypt.ToString())
        Console.WriteLine("  TrustServerCertificate = " & builder.TrustServerCertificate)
        Console.WriteLine("  Pooling             = " & builder.Pooling)
        Console.WriteLine("  Min / Max Pool Size = " & builder.MinPoolSize & " / " & builder.MaxPoolSize)
        Console.WriteLine("  Connect Timeout     = " & builder.ConnectTimeout)
        Console.WriteLine("  MultipleActiveResultSets = " & builder.MultipleActiveResultSets)

        builder.DataSource = "db.example.test"
        builder.InitialCatalog = "Shelf"
        builder.IntegratedSecurity = True
        builder.ApplicationName = "Shelf.Cli"
        builder.ConnectTimeout = 1
        Console.WriteLine("Built: " & builder.ConnectionString)

        ' Nothing listens on this loopback port, so the failure is immediate and needs no network.
        builder.DataSource = "127.0.0.1,1"
        Using connection As New SqlConnection(builder.ConnectionString)
            Try
                connection.Open()
            Catch ex As SqlException
                Console.WriteLine("SqlException number " & ex.Number & ", class " & ex.Class)
                Console.WriteLine("  message starts: " & ex.Message.Substring(0, ex.Message.IndexOf(" The server")))
            End Try
        End Using
    End Sub
End Module
Defaults of SqlConnectionStringBuilder:
  Encrypt             = True
  TrustServerCertificate = False
  Pooling             = True
  Min / Max Pool Size = 0 / 100
  Connect Timeout     = 15
  MultipleActiveResultSets = False
Built: Data Source=db.example.test;Initial Catalog=Shelf;Integrated Security=True;Connect Timeout=1;Application Name=Shelf.Cli
SqlException number 0, class 20
  message starts: A network-related or instance-specific error occurred while establishing a connection to SQL Server.

Read the defaults: pooling is on with a maximum of 100 connections per distinct connection string, the connect timeout is 15 seconds, and, since version 4 of Microsoft.Data.SqlClient, Encrypt is True and TrustServerCertificate is False. A program that used to connect to a development server with a self-signed certificate through System.Data.SqlClient therefore fails with a certificate error after the switch. The fix for a real server is a valid certificate. For a throw-away local server, TrustServerCertificate=True makes the error go away and removes the protection, so keep it out of production configuration. A failed connect raises SqlException, whose Number is 0 for transport failures and a server error number once the server answered; the text of the transport message varies by operating system, which is why the program prints only its first sentence.

Pooling rules that matter (SQL Server):

  • A pool belongs to one connection string (character for character), one process and, with integrated security, one identity. Ten strings that differ only by Application Name make ten pools.

  • A connection that is not disposed is not returned: the classic symptom is InvalidOperationException ("Timeout expired. The timeout period elapsed prior to obtaining a connection from the pool"). Visual Basic’s Using makes the correct code the short code; the leak appears when the connection is stored in a field and nobody owns its disposal.

  • SqlConnection.ClearAllPools() and ClearPool(connection) exist for tests and for failover; do not call them in normal code.

  • Microsoft.Data.Sqlite pools too (the Pooling keyword; New SqliteConnectionStringBuilder().Pooling is True, see the check below) and its in-memory databases live only as long as one connection stays open, which is why the EF examples below keep a connection open for the duration.

Connection strings come from configuration

A connection string holds a server name and often credentials. It does not belong in source code, in the repository, or in a compiled resource. Read it from configuration at start-up, and keep the secret part in a store that is not in the repository: environment variables, dotnet user-secrets for development, a cloud key vault or the platform’s secret manager in production, or integrated authentication so that there is no secret at all. Microsoft.Extensions.Configuration layers sources, and later sources override earlier ones. The program writes a small appsettings.json, then overrides the value with an environment variable (the double underscore stands for the : separator):

Option Strict On

Imports System.IO
Imports Microsoft.Data.Sqlite
Imports Microsoft.Extensions.Configuration

Module ConfigDemo
    Sub Main()
        Dim folder = Path.Combine(Path.GetTempPath(), "shelf-config-demo")
        Directory.CreateDirectory(folder)
        File.WriteAllText(Path.Combine(folder, "appsettings.json"),
            "{ ""ConnectionStrings"": { ""Shelf"": ""Data Source=shelf-dev.db;Cache=Shared"" } }")

        Dim configuration = New ConfigurationBuilder().
            SetBasePath(folder).
            AddJsonFile("appsettings.json", optional:=False).
            AddEnvironmentVariables(prefix:="SHELF_").
            Build()

        Dim fromFile = configuration.GetConnectionString("Shelf")
        Console.WriteLine("From appsettings.json: " & fromFile)

        Environment.SetEnvironmentVariable("SHELF_ConnectionStrings__Shelf", "Data Source=:memory:")
        Dim withEnvironment = New ConfigurationBuilder().
            SetBasePath(folder).
            AddJsonFile("appsettings.json", optional:=False).
            AddEnvironmentVariables(prefix:="SHELF_").
            Build()
        Dim effective = withEnvironment.GetConnectionString("Shelf")
        Console.WriteLine("With SHELF_ConnectionStrings__Shelf set: " & effective)

        Dim builder As New SqliteConnectionStringBuilder(effective)
        builder.Mode = SqliteOpenMode.ReadWriteCreate
        builder.Pooling = False
        Console.WriteLine("Normalised: " & builder.ConnectionString)

        Using connection As New SqliteConnection(builder.ConnectionString)
            connection.Open()
            Console.WriteLine("Opened, server version " & connection.ServerVersion.Split("."c)(0) & ".x")
        End Using
        File.Delete(Path.Combine(folder, "appsettings.json"))
    End Sub
End Module
From appsettings.json: Data Source=shelf-dev.db;Cache=Shared
With SHELF_ConnectionStrings__Shelf set: Data Source=:memory:
Normalised: Data Source=:memory:;Mode=ReadWriteCreate;Pooling=False
Opened, server version 3.x

Visual Basic specifics seen in this program: named arguments use := (optional:=False); the extension methods AddJsonFile and AddEnvironmentVariables need their packages and Imports Microsoft.Extensions.Configuration; and a local variable named directory would hide System.IO.Directory because names are not case-sensitive (the first version of this program failed with BC30456, 'CreateDirectory' is not a member of 'String', for exactly that reason). Use SqlConnectionStringBuilder (or SqliteConnectionStringBuilder) instead of concatenating strings: it validates keywords, escapes values and shows the normalised result, as the Normalised: line above does.

Commands, parameters and readers

The first program uses everything in the basic cycle: create a table, insert rows with parameters, read a scalar, and read rows with a reader. It was built and run:

Option Strict On

Imports Microsoft.Data.Sqlite

Module Basics
    Private Structure BookSeed
        Public Title As String
        Public Year As Integer
        Public Isbn As String
        Public Subtitle As String
    End Structure

    Sub Main()
        Dim seeds = {
            New BookSeed With {.Title = "Dune", .Year = 1965, .Isbn = "9780441172719"},
            New BookSeed With {.Title = "Neuromancer", .Year = 1984, .Isbn = "9780441569595", .Subtitle = "A novel"},
            New BookSeed With {.Title = "Hyperion", .Year = 1989, .Isbn = "9780553283686"}}

        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()

            Using create = connection.CreateCommand()
                create.CommandText =
                    "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NOT NULL UNIQUE, Subtitle TEXT NULL)"
                create.ExecuteNonQuery()
            End Using

            Using insert = connection.CreateCommand()
                insert.CommandText = "INSERT INTO Books (Title, Year, Isbn, Subtitle) VALUES ($title, $year, $isbn, $subtitle)"
                insert.Parameters.Add("$title", SqliteType.Text)
                insert.Parameters.Add("$year", SqliteType.Integer)
                insert.Parameters.Add("$isbn", SqliteType.Text)
                insert.Parameters.Add("$subtitle", SqliteType.Text)

                For Each seed In seeds
                    insert.Parameters("$title").Value = seed.Title
                    insert.Parameters("$year").Value = seed.Year
                    insert.Parameters("$isbn").Value = seed.Isbn
                    insert.Parameters("$subtitle").Value = If(seed.Subtitle Is Nothing, DBNull.Value, CObj(seed.Subtitle))
                    Console.WriteLine($"Inserted {insert.ExecuteNonQuery()} row: {seed.Title}")
                Next
            End Using

            Using count = connection.CreateCommand()
                count.CommandText = "SELECT COUNT(*) FROM Books WHERE Year > $year"
                count.Parameters.Add("$year", SqliteType.Integer).Value = 1970
                Dim total = CLng(count.ExecuteScalar())
                Console.WriteLine($"Books after 1970: {total}")
            End Using

            Using query = connection.CreateCommand()
                query.CommandText = "SELECT Id, Title, Year, Subtitle FROM Books ORDER BY Year"
                Using reader = query.ExecuteReader()
                    Dim title = reader.GetOrdinal("Title")
                    Dim subtitle = reader.GetOrdinal("Subtitle")
                    While reader.Read()
                        Dim text = If(reader.IsDBNull(subtitle), "(none)", reader.GetString(subtitle))
                        Console.WriteLine($"{reader.GetInt32(0)}: {reader.GetString(title)} [{reader.GetInt32(2)}] subtitle={text}")
                    End While
                End Using
            End Using
        End Using
    End Sub
End Module
Inserted 1 row: Dune
Inserted 1 row: Neuromancer
Inserted 1 row: Hyperion
Books after 1970: 2
1: Dune [1965] subtitle=(none)
2: Neuromancer [1984] subtitle=A novel
3: Hyperion [1989] subtitle=(none)

Points to take from it:

  • Parameters, always. Every value that comes from outside the program goes in a parameter. The SQL text stays constant, the provider sends the values separately and the database never parses them as SQL. With SQL Server the idiom is command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn: the type and size are explicit, so the plan cache sees one statement. Parameters.AddWithValue("@isbn", isbn) is shorter and lets the provider infer the type from the .NET value, which produces a different parameter size for each string length and, for String against a varchar column, an implicit conversion that can stop an index from being used. Prefer Add(name, type[, size]).Value. Microsoft.Data.Sqlite accepts both and is not affected by the plan-cache issue; every example on this page still uses typed Add, so the habit carries over to SQL Server unchanged.

  • Parameters.Add(...) returns the new parameter, so .Value = x can be chained onto it, as in command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn in the SQL Server listing below. The first program declares the parameters once and sets Value through the collection by name.

  • Reuse one command in a loop. Declare the parameters once, change Value for each row, call ExecuteNonQuery. The program above inserts three rows with one prepared command shape.

  • Readers are forward-only and hold the connection. Read by ordinal (GetOrdinal once, outside the loop) with the typed getters (GetInt32, GetString), not by reader("Title") in a loop, which does a name lookup for every row and returns Object that Option Strict On makes you convert.

  • ExecuteScalar returns Object: convert it (CLng(...); SQLite’s COUNT(*) is a 64-bit integer, SQL Server’s is a 32-bit one, and CInt on a boxed Long is a conversion, not an unboxing). ExecuteNonQuery returns the number of rows affected.

Nothing is not NULL

The most Visual Basic-specific mistake in data access is to pass Nothing for a missing value. In .NET, a database NULL is the singleton DBNull.Value; Nothing is a null reference, which the provider treats as "no value was supplied". This program shows what the provider does with each, and the other classic ADO.NET failures: a closed connection, a constraint violation, SQL built by concatenation, and reading NULL with a typed getter:

Option Strict On

Imports Microsoft.Data.Sqlite

Module Pitfalls
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            ' 1. A command on a closed connection
            Using command = connection.CreateCommand()
                command.CommandText = "SELECT 1"
                Try
                    command.ExecuteScalar()
                Catch ex As InvalidOperationException
                    Console.WriteLine("Closed connection: " & ex.Message)
                End Try
            End Using

            connection.Open()
            Using create = connection.CreateCommand()
                create.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Isbn TEXT NOT NULL UNIQUE, Subtitle TEXT NULL)"
                create.ExecuteNonQuery()
            End Using

            ' 2. Nothing is not NULL
            Using insert = connection.CreateCommand()
                insert.CommandText = "INSERT INTO Books (Title, Isbn, Subtitle) VALUES ($title, $isbn, $subtitle)"
                insert.Parameters.Add("$title", SqliteType.Text).Value = "Dune"
                insert.Parameters.Add("$isbn", SqliteType.Text).Value = "9780441172719"
                insert.Parameters.Add("$subtitle", SqliteType.Text).Value = Nothing
                Try
                    insert.ExecuteNonQuery()
                Catch ex As InvalidOperationException
                    Console.WriteLine("Value Nothing: " & ex.Message)
                End Try
                insert.Parameters("$subtitle").Value = DBNull.Value
                Console.WriteLine("Value DBNull.Value: inserted " & insert.ExecuteNonQuery())
            End Using

            ' 3. A constraint violation
            Using duplicate = connection.CreateCommand()
                duplicate.CommandText = "INSERT INTO Books (Title, Isbn) VALUES ('Dune again', '9780441172719')"
                Try
                    duplicate.ExecuteNonQuery()
                Catch ex As SqliteException
                    Console.WriteLine($"SqliteException {ex.SqliteErrorCode}/{ex.SqliteExtendedErrorCode}: {ex.Message}")
                End Try
            End Using

            ' 4. Concatenated text versus a parameter
            Dim hostile = "x' OR '1'='1"
            Using unsafeCommand = connection.CreateCommand()
                unsafeCommand.CommandText = "SELECT COUNT(*) FROM Books WHERE Title = '" & hostile & "'"
                Console.WriteLine("Concatenated: " & unsafeCommand.CommandText)
                Console.WriteLine("  rows matched: " & CLng(unsafeCommand.ExecuteScalar()))
            End Using
            Using safeCommand = connection.CreateCommand()
                safeCommand.CommandText = "SELECT COUNT(*) FROM Books WHERE Title = $title"
                safeCommand.Parameters.Add("$title", SqliteType.Text).Value = hostile
                Console.WriteLine("Parameterised: " & safeCommand.CommandText)
                Console.WriteLine("  rows matched: " & CLng(safeCommand.ExecuteScalar()))
            End Using

            ' 5. Reading a NULL column without checking
            Using query = connection.CreateCommand()
                query.CommandText = "SELECT Subtitle FROM Books"
                Using reader = query.ExecuteReader()
                    reader.Read()
                    Try
                        Console.WriteLine(reader.GetString(0))
                    Catch ex As InvalidOperationException
                        Console.WriteLine("GetString on NULL: " & ex.Message)
                    End Try
                    Console.WriteLine("GetValue on NULL is DBNull: " & (reader.GetValue(0) Is DBNull.Value))
                End Using
            End Using
        End Using
    End Sub
End Module
Closed connection: ExecuteScalar can only be called when the connection is open.
Value Nothing: Value must be set.
Value DBNull.Value: inserted 1
SqliteException 19/2067: SQLite Error 19: 'UNIQUE constraint failed: Books.Isbn'.
Concatenated: SELECT COUNT(*) FROM Books WHERE Title = 'x' OR '1'='1'
  rows matched: 1
Parameterised: SELECT COUNT(*) FROM Books WHERE Title = $title
  rows matched: 0
GetString on NULL: The data is NULL at ordinal 0. This method can't be called on NULL values. Check using IsDBNull before calling.
GetValue on NULL is DBNull: True

The fifth block is worth understanding because it is the SQL injection demonstration in miniature. The concatenated text x' OR '1'='1 closes the string literal and adds a condition that is always true, so the query matches a row that does not have that title; the same text sent as a parameter is just an unusual title and matches nothing. With a database that supports stacked statements the same technique deletes tables. SQL injection is the only item on this page that can end a company, and the defence is mechanical: parameters for values, an allow-list for identifiers (a column name cannot be a parameter), least-privilege database accounts, and no string building of SQL. See Security and Cryptography for the rest of the security picture.

NULL handling in Visual Basic, in one table:

Task Visual Basic spelling

Send a NULL

parameter.Value = DBNull.Value; for an optional value If(value Is Nothing, CObj(DBNull.Value), CObj(value)) (both branches must be Object, or Option Strict complains about the If operands).

Test a column

reader.IsDBNull(ordinal) before a typed getter; the getters throw InvalidOperationException on NULL with Microsoft.Data.Sqlite (SqlClient throws SqlNullValueException).

Map to a nullable value

If(reader.IsDBNull(i), CType(Nothing, Integer?), reader.GetInt32(i)): one operand must be typed, because Nothing alone has no type for If.

Compare in the SQL

WHERE Subtitle IS NULL, never = NULL; in EF Core LINQ, Where(Function(b) b.Subtitle Is Nothing) is translated to IS NULL.

Transactions

A transaction makes a group of commands atomic. BeginTransaction returns an object that must be disposed; if neither Commit nor Rollback ran, disposing rolls back. With Microsoft.Data.Sqlite (as with SqlClient when you use explicit transactions) each command must be told which transaction it belongs to. This program moves stock between two shelves; the second transfer violates a CHECK constraint and rolls back:

Option Strict On

Imports Microsoft.Data.Sqlite

Module Transactions
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Execute(connection, "CREATE TABLE Accounts (Name TEXT PRIMARY KEY, Balance INTEGER NOT NULL CHECK (Balance >= 0))")
            Execute(connection, "INSERT INTO Accounts VALUES ('Reading room', 100), ('Archive', 50)")

            Transfer(connection, "Reading room", "Archive", 30)
            Transfer(connection, "Reading room", "Archive", 500)
            PrintBalances(connection)
        End Using
    End Sub

    Private Sub Transfer(connection As SqliteConnection, source As String, target As String, amount As Integer)
        Using transaction = connection.BeginTransaction()
            Try
                Move(connection, transaction, source, -amount)
                Move(connection, transaction, target, amount)
                transaction.Commit()
                Console.WriteLine($"Committed: {amount} from {source} to {target}")
            Catch ex As SqliteException
                transaction.Rollback()
                Console.WriteLine($"Rolled back ({ex.SqliteErrorCode}): {ex.Message}")
            End Try
        End Using
    End Sub

    Private Sub Move(connection As SqliteConnection, transaction As SqliteTransaction, name As String, delta As Integer)
        Using command = connection.CreateCommand()
            command.Transaction = transaction
            command.CommandText = "UPDATE Accounts SET Balance = Balance + $delta WHERE Name = $name"
            command.Parameters.Add("$delta", SqliteType.Integer).Value = delta
            command.Parameters.Add("$name", SqliteType.Text).Value = name
            command.ExecuteNonQuery()
        End Using
    End Sub

    Private Sub Execute(connection As SqliteConnection, sql As String)
        Using command = connection.CreateCommand()
            command.CommandText = sql
            command.ExecuteNonQuery()
        End Using
    End Sub

    Private Sub PrintBalances(connection As SqliteConnection)
        Using command = connection.CreateCommand()
            command.CommandText = "SELECT Name, Balance FROM Accounts ORDER BY Name"
            Using reader = command.ExecuteReader()
                While reader.Read()
                    Console.WriteLine($"{reader.GetString(0)}: {reader.GetInt32(1)}")
                End While
            End Using
        End Using
    End Sub
End Module
Committed: 30 from Reading room to Archive
Rolled back (19): SQLite Error 19: 'CHECK constraint failed: Balance >= 0'.
Archive: 80
Reading room: 70

Rules of thumb: keep transactions short, open them as late as possible and commit as early as possible; never wait for a user inside one; catch the specific exception you can handle (SqlException with a known Number, such as 1205 for a deadlock victim in SQL Server, after which the whole unit of work may be retried) and roll back in every other case. System.Transactions.TransactionScope is the ambient alternative and also works with the providers; it enlists connections that open inside the scope, which can silently escalate to a distributed transaction when two connection strings are used. Prefer the explicit transaction unless the code you call is outside your control.

Async ADO.NET

Every I/O method has an async twin (OpenAsync, ExecuteReaderAsync, ReadAsync, ExecuteNonQueryAsync) that takes a CancellationToken. Visual Basic has no Await Using, so the code uses a plain Using for synchronous disposal or, when the asynchronous disposal matters, a Try/Finally with an explicit Dispose. Awaiting inside Finally is not allowed:

Advanced.vb(47,38): error BC36943: 'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.

This program was built and run; it also shows cancellation (a token that is already cancelled stops the call before it starts):

Option Strict On

Imports System.Threading
Imports Microsoft.Data.Sqlite

Module AsyncAdo
    Sub Main()
        RunAsync().GetAwaiter().GetResult()
    End Sub

    Private Async Function RunAsync() As Task
        Dim connection As New SqliteConnection("Data Source=:memory:")
        Try
            Await connection.OpenAsync()
            Using create = connection.CreateCommand()
                create.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL)"
                Await create.ExecuteNonQueryAsync()
            End Using
            Using insert = connection.CreateCommand()
                insert.CommandText = "INSERT INTO Books (Title) VALUES ('Dune'), ('Hyperion'), ('Neuromancer')"
                Console.WriteLine("Inserted: " & Await insert.ExecuteNonQueryAsync())
            End Using

            Using query = connection.CreateCommand()
                query.CommandText = "SELECT Title FROM Books ORDER BY Title"
                Using reader = Await query.ExecuteReaderAsync()
                    While Await reader.ReadAsync()
                        Console.WriteLine("Read: " & reader.GetString(0))
                    End While
                End Using
            End Using

            Using cancelled As New CancellationTokenSource()
                cancelled.Cancel()
                Using query = connection.CreateCommand()
                    query.CommandText = "SELECT COUNT(*) FROM Books"
                    Try
                        Await query.ExecuteScalarAsync(cancelled.Token)
                    Catch ex As OperationCanceledException
                        Console.WriteLine("Cancelled before it started: " & ex.GetType().Name)
                    End Try
                End Using
            End Using
        Finally
            connection.Dispose()
        End Try
    End Function
End Module
Inserted: 3
Read: Dune
Read: Hyperion
Read: Neuromancer
Cancelled before it started: TaskCanceledException

Two details of this output. The exception type is TaskCanceledException, a subclass of OperationCanceledException; catch the base class. And Microsoft.Data.Sqlite is synchronous underneath (SQLite has no asynchronous I/O API), so its async methods only honour cancellation before the call; the SQL Server provider is genuinely asynchronous and cancels an in-flight command. The general rules for Async/Await, including the Async Sub restriction and why an Async Function Main is not allowed in Visual Basic, are on Async and Await.

SQL Server with Microsoft.Data.SqlClient

The code below is the SQL Server counterpart of the examples above: a typed lookup, a stored procedure with an output parameter, an explicit transaction with a deadlock-aware Catch ... When, an async reader with a cancellation token, a DataAdapter with generated update commands and a bulk copy. It was built, not run: nothing here can execute without a server. The connection string is read from an environment variable with a safe development default, and the class that owns it is the only place that knows about it.

Option Strict On

Imports System.Data
Imports System.Threading
Imports Microsoft.Data.SqlClient

' Built, not run: every method here needs a SQL Server that the build machine does not have.
Public Class SqlServerCatalog
    Private ReadOnly _connectionString As String

    ' The connection string comes from configuration or an environment variable, never from source code.
    Public Sub New()
        _connectionString = If(Environment.GetEnvironmentVariable("SHELF_SQL"), "Server=localhost;Database=Shelf;Integrated Security=True")
    End Sub

    ' Typed parameter, explicit size: one cached plan for every title length.
    Public Function FindIdByIsbn(isbn As String) As Integer?
        Using connection As New SqlConnection(_connectionString)
            connection.Open()
            Using command As New SqlCommand("SELECT Id FROM dbo.Books WHERE Isbn = @isbn", connection)
                command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn
                Dim result = command.ExecuteScalar()
                Return If(result Is Nothing OrElse result Is DBNull.Value, CType(Nothing, Integer?), CInt(result))
            End Using
        End Using
    End Function

    ' Stored procedure with an output parameter and a return value.
    Public Function AddBook(title As String, year As Integer, isbn As String) As Integer
        Using connection As New SqlConnection(_connectionString)
            connection.Open()
            Using command As New SqlCommand("dbo.AddBook", connection)
                command.CommandType = CommandType.StoredProcedure
                command.Parameters.Add("@title", SqlDbType.NVarChar, 200).Value = title
                command.Parameters.Add("@year", SqlDbType.Int).Value = year
                command.Parameters.Add("@isbn", SqlDbType.NChar, 13).Value = isbn
                Dim newId = command.Parameters.Add("@id", SqlDbType.Int)
                newId.Direction = ParameterDirection.Output
                command.ExecuteNonQuery()
                Return CInt(newId.Value)
            End Using
        End Using
    End Function

    ' Transaction: both commands enlist explicitly.
    Public Sub Rename(id As Integer, title As String)
        Using connection As New SqlConnection(_connectionString)
            connection.Open()
            Using transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted)
                Try
                    Using command As New SqlCommand("UPDATE dbo.Books SET Title = @title WHERE Id = @id", connection, transaction)
                        command.Parameters.Add("@title", SqlDbType.NVarChar, 200).Value = title
                        command.Parameters.Add("@id", SqlDbType.Int).Value = id
                        command.ExecuteNonQuery()
                    End Using
                    transaction.Commit()
                Catch ex As SqlException When ex.Number = 1205
                    transaction.Rollback()
                    Throw New InvalidOperationException("Chosen as a deadlock victim; retry the whole unit of work.", ex)
                Catch
                    transaction.Rollback()
                    Throw
                End Try
            End Using
        End Using
    End Sub

    ' Async with a cancellation token. The Using block (not Await Using) disposes synchronously.
    Public Async Function GetTitlesAsync(minimumYear As Integer, token As CancellationToken) As Task(Of List(Of String))
        Dim titles As New List(Of String)
        Using connection As New SqlConnection(_connectionString)
            Await connection.OpenAsync(token)
            Using command As New SqlCommand("SELECT Title FROM dbo.Books WHERE Year >= @year ORDER BY Title", connection)
                command.Parameters.Add("@year", SqlDbType.Int).Value = minimumYear
                Using reader = Await command.ExecuteReaderAsync(token)
                    While Await reader.ReadAsync(token)
                        titles.Add(reader.GetString(0))
                    End While
                End Using
            End Using
        End Using
        Return titles
    End Function

    ' DataAdapter with generated update commands; the SELECT must return the primary key.
    Public Sub SaveChanges(table As DataTable)
        Using connection As New SqlConnection(_connectionString)
            Using adapter As New SqlDataAdapter("SELECT Id, Title, Year, Isbn FROM dbo.Books", connection)
                Using builder As New SqlCommandBuilder(adapter)
                    Dim updated = adapter.Update(table)
                    Console.WriteLine("Rows written: " & updated)
                End Using
            End Using
        End Using
    End Sub

    ' Bulk load.
    Public Sub Import(rows As DataTable)
        Using connection As New SqlConnection(_connectionString)
            connection.Open()
            Using copy As New SqlBulkCopy(connection) With {.DestinationTableName = "dbo.Books", .BatchSize = 1000}
                copy.WriteToServer(rows)
            End Using
        End Using
    End Sub
End Class

Visual Basic notes on this listing:

  • Catch ex As SqlException When ex.Number = 1205 is Visual Basic’s exception filter (C# added the same idea as catch ... when in C# 6). The filter runs before the stack unwinds, so an exception that no Catch matches is not caught at all.

  • Return If(result Is Nothing OrElse result Is DBNull.Value, CType(Nothing, Integer?), CInt(result)) is how a function returns Integer?: the CType(Nothing, Integer?) types the first operand, and CInt converts to Integer, which widens to Integer?.

  • A stored procedure needs CommandType.StoredProcedure, the procedure name as the command text, and ParameterDirection.Output for output parameters, whose Value is available after ExecuteNonQuery (and, for a reader, only after the reader is closed).

  • SqlCommandBuilder generates the INSERT, UPDATE and DELETE for an adapter from its SELECT, which must return the primary key. It is convenient for tools and small tables; for a real data layer, write the commands or use EF Core.

  • SqlBulkCopy is the fast way to load many rows; it has no equivalent in EF Core (EF’s own bulk helpers are ExecuteUpdate and ExecuteDelete).

  • The Using blocks dispose synchronously even inside Async methods. Provider objects implement IAsyncDisposable, but without Await Using Visual Basic code cannot call DisposeAsync in a Finally; in practice the synchronous Dispose of a pooled connection is cheap.

DataSet, DataTable and DataView

A DataTable is an in-memory table with rows that track their own state (Added, Modified, Deleted, Unchanged); a DataSet is a group of tables with DataRelation`s between them; a `DataView is a sorted and filtered window onto a table. They predate LINQ and EF, are still supported on .NET 10, and remain useful for ad hoc reports, for binding to a grid and for code ported from .NET Framework. Microsoft.Data.Sqlite has no DbDataAdapter, so this program fills the tables from a reader with DataTable.Load. The check that supports the claim about the missing adapter:

Option Strict On

Imports System.Data.Common
Imports System.Reflection

Module AdapterCheck
    Sub Main()
        Dim assembly As Assembly = GetType(Microsoft.Data.Sqlite.SqliteConnection).Assembly
        Dim adapters = assembly.GetExportedTypes().Where(Function(t) GetType(DbDataAdapter).IsAssignableFrom(t)).Select(Function(t) t.Name).ToList()
        Dim sqlClientAdapter = GetType(Microsoft.Data.Sqlite.SqliteConnection).Assembly.GetName().Name
        Console.WriteLine(sqlClientAdapter & " exports " & adapters.Count & " DbDataAdapter type(s)")
        Console.WriteLine("Pooling default: " & New Microsoft.Data.Sqlite.SqliteConnectionStringBuilder().Pooling)
        Console.WriteLine("Public types in the assembly: " & String.Join(", ", assembly.GetExportedTypes().Select(Function(t) t.Name).Take(8)) & ", ...")
    End Sub
End Module
Microsoft.Data.Sqlite exports 0 DbDataAdapter type(s)
Pooling default: True
Public types in the assembly: SqliteBlob, SqliteCacheMode, SqliteCommand, SqliteConnection, SqliteConnectionStringBuilder, SqliteDataReader, SqliteException, SqliteFactory, ...

And the program that builds a DataSet, relates the tables, filters a view and watches row states:

Option Strict On

Imports System.Data
Imports Microsoft.Data.Sqlite

Module DataSets
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Run(connection, "CREATE TABLE Authors (Id INTEGER PRIMARY KEY, Name TEXT NOT NULL)")
            Run(connection, "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, AuthorId INTEGER NOT NULL)")
            Run(connection, "INSERT INTO Authors VALUES (1, 'Frank Herbert'), (2, 'William Gibson')")
            Run(connection, "INSERT INTO Books VALUES (1, 'Dune', 1965, 1), (2, 'Dune Messiah', 1969, 1), (3, 'Neuromancer', 1984, 2)")

            Dim data As New DataSet("Shelf")
            For Each name In {"Authors", "Books"}
                Using command = connection.CreateCommand()
                    command.CommandText = "SELECT * FROM " & name
                    Using reader = command.ExecuteReader()
                        Dim table = data.Tables.Add(name)
                        table.Load(reader)
                    End Using
                End Using
            Next
            data.Relations.Add("AuthorBooks", data.Tables("Authors").Columns("Id"), data.Tables("Books").Columns("AuthorId"))

            For Each author As DataRow In data.Tables("Authors").Rows
                Dim titles = author.GetChildRows("AuthorBooks").Select(Function(r) r.Field(Of String)("Title"))
                Console.WriteLine($"{author.Field(Of String)("Name")}: {String.Join(", ", titles)}")
            Next

            Dim books = data.Tables("Books")
            Dim view As New DataView(books) With {.RowFilter = "Year > 1966", .Sort = "Year DESC"}
            Console.WriteLine("View rows: " & String.Join(", ", view.Cast(Of DataRowView)().Select(Function(v) CStr(v("Title")))))

            books.AcceptChanges()
            books.Rows(0)("Year") = 1966
            books.Rows.Add(4, "Count Zero", 1986, 2)
            books.Rows(1).Delete()
            For Each row As DataRow In books.Rows
                Console.WriteLine($"{row.RowState}: {If(row.RowState = DataRowState.Deleted, CStr(row("Title", DataRowVersion.Original)), CStr(row("Title")))}")
            Next

            Dim changes = books.GetChanges()
            Console.WriteLine("Changed rows: " & changes.Rows.Count)
        End Using
    End Sub

    Private Sub Run(connection As SqliteConnection, sql As String)
        Using command = connection.CreateCommand()
            command.CommandText = sql
            command.ExecuteNonQuery()
        End Using
    End Sub
End Module
Frank Herbert: Dune, Dune Messiah
William Gibson: Neuromancer
View rows: Neuromancer, Dune Messiah
Modified: Dune
Deleted: Dune Messiah
Unchanged: Neuromancer
Added: Count Zero
Changed rows: 3

Notes: row.Field(Of String)("Title") (from System.Data.DataSetExtensions, which is part of the shared framework) returns a typed value and handles DBNull; row("Title") returns Object. The row filter and sort are strings in a small expression language, not Visual Basic, and DataView is not type-checked by the compiler. A deleted row cannot be read normally; ask for DataRowVersion.Original. With SqlDataAdapter (see the SQL Server listing) adapter.Update(table) sends exactly the Added, Modified and Deleted rows to the database.

A repository over ADO.NET

The Shelf.Core project defines ICatalogStore; any store that implements it can be plugged into Catalog. The class below implements it over SQLite with plain ADO.NET, mapping the Book/Magazine hierarchy to a single table with a Kind discriminator. It is the classic data-access layer: SQL is in one class, parameters are always used, and the rest of the program sees only the interface.

Option Strict On

Imports Microsoft.Data.Sqlite
Imports Shelf.Core

' ICatalogStore from Shelf.Core, implemented over ADO.NET.
Public Class SqliteCatalogStore
    Implements ICatalogStore

    Private ReadOnly _connection As SqliteConnection

    Public Sub New(connection As SqliteConnection)
        _connection = connection
        Using command = _connection.CreateCommand()
            command.CommandText =
                "CREATE TABLE IF NOT EXISTS Items (Id INTEGER PRIMARY KEY, Kind TEXT NOT NULL, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NULL, Issue INTEGER NULL, Authors TEXT NULL)"
            command.ExecuteNonQuery()
        End Using
    End Sub

    Public Sub Add(item As CatalogItem) Implements ICatalogStore.Add
        Using command = _connection.CreateCommand()
            command.CommandText = "INSERT INTO Items (Kind, Title, Year, Isbn, Issue, Authors) VALUES ($kind, $title, $year, $isbn, $issue, $authors); SELECT last_insert_rowid()"
            command.Parameters.Add("$kind", SqliteType.Text).Value = item.Kind
            command.Parameters.Add("$title", SqliteType.Text).Value = item.Title
            command.Parameters.Add("$year", SqliteType.Integer).Value = item.Year
            Dim book = TryCast(item, Book)
            Dim magazine = TryCast(item, Magazine)
            command.Parameters.Add("$isbn", SqliteType.Text).Value = If(book Is Nothing, CObj(DBNull.Value), CObj(book.Isbn.Value))
            command.Parameters.Add("$issue", SqliteType.Integer).Value = If(magazine Is Nothing, CObj(DBNull.Value), CObj(magazine.IssueNumber))
            command.Parameters.Add("$authors", SqliteType.Text).Value = If(book Is Nothing, CObj(DBNull.Value), CObj(String.Join("; ", book.Authors)))
            item.Id = CInt(CLng(command.ExecuteScalar()))
        End Using
    End Sub

    Public Function GetAll() As IReadOnlyList(Of CatalogItem) Implements ICatalogStore.GetAll
        Dim result As New List(Of CatalogItem)
        Using command = _connection.CreateCommand()
            command.CommandText = "SELECT Id, Kind, Title, Year, Isbn, Issue, Authors FROM Items ORDER BY Id"
            Using reader = command.ExecuteReader()
                While reader.Read()
                    result.Add(Materialize(reader))
                End While
            End Using
        End Using
        Return result
    End Function

    Public Function Find(id As Integer) As CatalogItem Implements ICatalogStore.Find
        Using command = _connection.CreateCommand()
            command.CommandText = "SELECT Id, Kind, Title, Year, Isbn, Issue, Authors FROM Items WHERE Id = $id"
            command.Parameters.Add("$id", SqliteType.Integer).Value = id
            Using reader = command.ExecuteReader()
                Return If(reader.Read(), Materialize(reader), Nothing)
            End Using
        End Using
    End Function

    Private Shared Function Materialize(reader As SqliteDataReader) As CatalogItem
        Dim item As CatalogItem
        If reader.GetString(1) = "Book" Then
            Dim authors = reader.GetString(6).Split({"; "}, StringSplitOptions.RemoveEmptyEntries).Select(Function(name) New Author(name)).ToArray()
            item = New Book(reader.GetString(2), reader.GetInt32(3), Isbn.Parse(reader.GetString(4)), authors)
        Else
            item = New Magazine(reader.GetString(2), reader.GetInt32(3), reader.GetInt32(5))
        End If
        item.Id = reader.GetInt32(0)
        Return item
    End Function
End Class

Module Repository
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Dim catalog As New Catalog(New SqliteCatalogStore(connection))
            catalog.Add(New Book("Dune", 1965, Isbn.Parse("9780441172719"), New Author("Frank Herbert")))
            catalog.Add(New Magazine("Byte", 1984, 12))
            catalog.Add(New Book("Neuromancer", 1984, Isbn.Parse("9780441569595"), New Author("William Gibson")))

            For Each item In catalog.Search("e")
                Console.WriteLine($"{item.Id}: {item.Describe()}")
            Next
        End Using
    End Sub
End Module
2: Magazine: Byte (1984)
1: Book: Dune (1965) by Frank Herbert
3: Book: Neuromancer (1984) by William Gibson

Design notes: the store takes an open connection from its owner, so one unit of work can share a transaction between stores; Materialize is the only place that knows the column order; and the Authors of a Book are stored in one text column for brevity, which is a deliberate simplification (a real schema has an Authors table and a link table, which is what the EF Core model below uses).

Caching in front of the store

A cache in front of a repository is the simplest performance step and the easiest to get wrong. The in-memory cache from Microsoft.Extensions.Caching.Memory replaces System.Runtime.Caching.MemoryCache of .NET Framework; the program counts database queries to show the effect:

Option Strict On

Imports Microsoft.Data.Sqlite
Imports Microsoft.Extensions.Caching.Memory

Module CacheDemo
    Private _queries As Integer

    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Using setup = connection.CreateCommand()
                setup.CommandText = "CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT); INSERT INTO Books (Title) VALUES ('Dune')"
                setup.ExecuteNonQuery()
            End Using

            Using cache As New MemoryCache(New MemoryCacheOptions())
                For i = 1 To 3
                    Dim title = cache.GetOrCreate("book:1",
                        Function(entry)
                            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
                            Return LoadTitle(connection, 1)
                        End Function)
                    Console.WriteLine($"Request {i}: {title} (database queries so far: {_queries})")
                Next
                cache.Remove("book:1")
                Console.WriteLine("After Remove: " & cache.GetOrCreate("book:1", Function(entry) LoadTitle(connection, 1)) & " (queries: " & _queries & ")")
            End Using
        End Using
    End Sub

    Private Function LoadTitle(connection As SqliteConnection, id As Integer) As String
        _queries += 1
        Using command = connection.CreateCommand()
            command.CommandText = "SELECT Title FROM Books WHERE Id = $id"
            command.Parameters.Add("$id", SqliteType.Integer).Value = id
            Return CStr(command.ExecuteScalar())
        End Using
    End Function
End Module
Request 1: Dune (database queries so far: 1)
Request 2: Dune (database queries so far: 1)
Request 3: Dune (database queries so far: 1)
After Remove: Dune (queries: 2)

Source: Caching in .NET

GetOrCreate takes a Func(Of ICacheEntry, TItem); in Visual Basic a multi-line Function(entry) ... End Function lambda is required when the body has a statement, as with the expiration setting here. Cache keys are strings, so build them in one place; set an expiration; and invalidate (Remove) when you write. Remember that a cache is state: do not cache anything that depends on the current user unless the user is in the key, and do not cache entities that are tracked by an EF context.

Dapper

Dapper is a micro-ORM: extension methods on IDbConnection that run SQL and map rows to objects by column name. It is a community library (not from Microsoft) and is widely used from Visual Basic as well as C#. It adds nothing beyond what ADO.NET provides except the mapping code, which is what makes it fast and what keeps it out of the way. The program was built and run with Dapper 2.1.89:

Option Strict On

Imports Dapper
Imports Microsoft.Data.Sqlite

Public Class BookRow
    Public Property Id As Integer
    Public Property Title As String = ""
    Public Property Year As Integer
    Public Property Isbn As String = ""
End Class

Module DapperDemo
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            connection.Execute("CREATE TABLE Books (Id INTEGER PRIMARY KEY, Title TEXT NOT NULL, Year INTEGER NOT NULL, Isbn TEXT NOT NULL)")
            Dim inserted = connection.Execute(
                "INSERT INTO Books (Title, Year, Isbn) VALUES (@Title, @Year, @Isbn)",
                {New BookRow With {.Title = "Dune", .Year = 1965, .Isbn = "9780441172719"},
                 New BookRow With {.Title = "Neuromancer", .Year = 1984, .Isbn = "9780441569595"}})
            Console.WriteLine("Inserted: " & inserted)

            Dim newer = connection.Query(Of BookRow)("SELECT * FROM Books WHERE Year > @minimum ORDER BY Year", New With {.minimum = 1970}).ToList()
            For Each book In newer
                Console.WriteLine($"{book.Id}: {book.Title} ({book.Year})")
            Next

            Dim count = connection.ExecuteScalar(Of Long)("SELECT COUNT(*) FROM Books")
            Console.WriteLine("Count: " & count)

            Dim first = connection.QuerySingleOrDefault(Of BookRow)("SELECT * FROM Books WHERE Isbn = @isbn", New With {.isbn = "0000000000000"})
            Console.WriteLine("Unknown ISBN gives Nothing: " & (first Is Nothing))
        End Using
    End Sub
End Module
Inserted: 2
2: Neuromancer (1984)
Count: 2
Unknown ISBN gives Nothing: True

Visual Basic notes: parameters are passed as an anonymous type (New With {.minimum = 1970}), whose property names become the @minimum parameter; the usual mapping target is a class with a parameterless constructor and settable properties named as the columns (name matching is case-insensitive, which suits Visual Basic); QuerySingleOrDefault(Of T) returns Nothing, not an exception, for no row (but throws when more than one row matches); and the generic argument is (Of BookRow), which is where C# writes <BookRow>. Dapper makes SQL injection as easy as plain ADO.NET if you concatenate: the parameter syntax is the protection.

Entity Framework Core from Visual Basic

EF Core 10 is the version that ships with .NET 10. Everything in the runtime works from Visual Basic: the model, LINQ translation (both syntaxes), change tracking, SaveChanges, concurrency, raw SQL and seeding. What C# has and Visual Basic lacks are, again, a language feature (nullable reference types, so EF Core cannot infer which columns are required) and tooling (the design-time code generators for migrations and scaffolding emit C#; discussed below).

The model and the context

The entities are plain classes. A navigation collection is initialised with As New List(Of BookEntity). The context configures the model in OnModelCreating with the fluent API, using lambdas (Function(b) b.Title, and Sub(book) ... for the entity configuration block). The ISBN is the Isbn structure from Shelf.Core, stored as text through a value converter. The context takes its options through the constructor, so the same class can be used with SQLite in tests and another provider in production.

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <RootNamespace>ShelfApp</RootNamespace>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.13" />
    <ProjectReference Include="..\Shelf.Core\Shelf.Core.vbproj" />
  </ItemGroup>
</Project>
Option Strict On

Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.Storage.ValueConversion
Imports Shelf.Core

Public Class AuthorEntity
    Public Property Id As Integer
    Public Property Name As String = ""
    Public Property Books As New List(Of BookEntity)
End Class

Public Class BookEntity
    Public Property Id As Integer
    Public Property Title As String = ""
    Public Property Year As Integer
    Public Property Isbn As Isbn
    Public Property Version As Integer
    Public Property AuthorId As Integer
    Public Property Author As AuthorEntity
End Class

Public Class ShelfContext
    Inherits DbContext

    Public Sub New(options As DbContextOptions(Of ShelfContext))
        MyBase.New(options)
    End Sub

    Public Property Authors As DbSet(Of AuthorEntity)
    Public Property Books As DbSet(Of BookEntity)

    Protected Overrides Sub OnModelCreating(modelBuilder As ModelBuilder)
        modelBuilder.Entity(Of AuthorEntity)(
            Sub(author)
                author.Property(Function(a) a.Name).IsRequired().HasMaxLength(100)
                author.HasMany(Function(a) a.Books).WithOne(Function(b) b.Author).HasForeignKey(Function(b) b.AuthorId)
            End Sub)

        modelBuilder.Entity(Of BookEntity)(
            Sub(book)
                book.Property(Function(b) b.Title).IsRequired().HasMaxLength(200)
                book.Property(Function(b) b.Version).IsConcurrencyToken()
                book.Property(Function(b) b.Isbn).HasConversion(New IsbnConverter()).HasMaxLength(13)
                book.HasIndex(Function(b) b.Isbn).IsUnique()
            End Sub)

        modelBuilder.Entity(Of AuthorEntity)().HasData(
            New AuthorEntity With {.Id = 1, .Name = "Frank Herbert"},
            New AuthorEntity With {.Id = 2, .Name = "William Gibson"})
    End Sub
End Class

Public Class IsbnConverter
    Inherits ValueConverter(Of Isbn, String)

    Public Sub New()
        MyBase.New(Function(i) i.Value, Function(s) Isbn.Parse(s))
    End Sub
End Class

Public Module Db
    Public Function CreateOptions(connection As SqliteConnection) As DbContextOptions(Of ShelfContext)
        Return New DbContextOptionsBuilder(Of ShelfContext)().UseSqlite(connection).Options
    End Function

    ' One open in-memory connection keeps the database alive for the whole run.
    Public Function OpenDatabase() As SqliteConnection
        Dim connection As New SqliteConnection("Data Source=:memory:")
        connection.Open()
        Using context As New ShelfContext(CreateOptions(connection))
            context.Database.EnsureCreated()
        End Using
        Return connection
    End Function
End Module

Four Visual Basic details in this file:

  • Required columns must be declared. Visual Basic has no String? versus String, so EF Core sees every String property as nullable. A value-type property (Integer, Date) is required; a Nullable(Of T) one (Integer?) is optional. This is the DDL that EF Core generated for a model with no configuration (run):

    Option Strict On
    
    Imports Microsoft.EntityFrameworkCore
    
    Public Class Loose
        Public Property Id As Integer
        Public Property Title As String
        Public Property Subtitle As String
        Public Property Pages As Integer
        Public Property Rating As Integer?
        Public Property Published As Date
        Public Property Reviewed As Date?
    End Class
    
    Public Class LooseContext
        Inherits DbContext
    
        Public Property Items As DbSet(Of Loose)
    
        Protected Overrides Sub OnConfiguring(optionsBuilder As DbContextOptionsBuilder)
            optionsBuilder.UseSqlite("Data Source=:memory:")
        End Sub
    End Class
    
    Module Nullability
        Sub Main()
            Using context As New LooseContext()
                Console.WriteLine(context.Database.GenerateCreateScript())
                Console.WriteLine(context.Items.Where(Function(i) i.Subtitle Is Nothing).ToQueryString())
            End Using
        End Sub
    End Module
    CREATE TABLE "Items" (
        "Id" INTEGER NOT NULL CONSTRAINT "PK_Items" PRIMARY KEY AUTOINCREMENT,
        "Title" TEXT NULL,
        "Subtitle" TEXT NULL,
        "Pages" INTEGER NOT NULL,
        "Rating" INTEGER NULL,
        "Published" TEXT NOT NULL,
        "Reviewed" TEXT NULL
    );
    
    
    
    SELECT "i"."Id", "i"."Pages", "i"."Published", "i"."Rating", "i"."Reviewed", "i"."Subtitle", "i"."Title"
    FROM "Items" AS "i"
    WHERE "i"."Subtitle" IS NULL

    Title and Subtitle are both TEXT NULL; the model above calls IsRequired() for the properties that must not be NULL. The last statement of the program shows that a comparison with Is Nothing in a query is translated to IS NULL.

  • Case-insensitive names and the DbSet properties. Public Property Books As DbSet(Of BookEntity) is initialised by EF Core when the context is created. A property named Books in the context and an entity class named Book can coexist, but a local variable named books hides the property in the same method (names are not case-sensitive); the entity classes here are suffixed with Entity so that they do not collide with the Book of Shelf.Core.

  • Nothing is an acceptable value for a reference navigation and for strings; give String properties a default of "" (Public Property Title As String = "") when you want to avoid NullReferenceException in code that reads an unset property.

  • Lambda expressions that EF Core translates must be expression-tree compatible. A single-expression lambda such as Function(b) b.Year > 1960 converts to an expression tree like a C# one. A lambda with statements does not, and the compiler says so:

    Option Strict On
    
    Imports Microsoft.EntityFrameworkCore
    
    Module Fail
        Sub Main()
            Using connection = OpenDatabase()
                Using context As New ShelfContext(CreateOptions(connection))
                    Dim query = context.Books.Where(Function(b)
                                                        Dim limit = 1960
                                                        Return b.Year > limit
                                                    End Function)
                    Console.WriteLine(query.Count())
                End Using
            End Using
        End Sub
    End Module
    Fail.vb(9,49): error BC36675: Statement lambdas cannot be converted to expression trees.

    Constructs that Visual Basic compiles to runtime-library calls instead of plain operators are not translatable either (the next two sections).

Create, read, update, delete and change tracking

The context tracks every entity it loads or you attach. Entry(entity).State shows what SaveChanges will do. This program was run:

Option Strict On

Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core

Module Crud
    Sub Main()
        Using connection = OpenDatabase()
            Using context As New ShelfContext(CreateOptions(connection))
                Dim herbert = context.Authors.Single(Function(a) a.Name = "Frank Herbert")
                Dim dune As New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .Author = herbert}
                Console.WriteLine("Before Add: " & context.Entry(dune).State.ToString())
                context.Books.Add(dune)
                Console.WriteLine("After Add:  " & context.Entry(dune).State.ToString())
                context.SaveChanges()
                Console.WriteLine("After Save: " & context.Entry(dune).State.ToString() & ", Id=" & dune.Id)

                dune.Year = 1966
                Console.WriteLine("After edit: " & context.Entry(dune).State.ToString())
                context.ChangeTracker.DetectChanges()
                Console.WriteLine(context.ChangeTracker.DebugView.ShortView)
                context.SaveChanges()
            End Using

            Using context As New ShelfContext(CreateOptions(connection))
                Dim loaded = context.Books.AsNoTracking().Single()
                Console.WriteLine($"Reloaded: {loaded.Title} {loaded.Year} {loaded.Isbn}")

                Dim changed = context.Books.Where(Function(b) b.Year < 2000).
                    ExecuteUpdate(Function(setters) setters.SetProperty(Function(b) b.Year, Function(b) b.Year + 1))
                Console.WriteLine("ExecuteUpdate rows: " & changed)
                Console.WriteLine("Year now: " & context.Books.Select(Function(b) b.Year).Single())

                Dim deleted = context.Books.Where(Function(b) b.Title = "Dune").ExecuteDelete()
                Console.WriteLine("ExecuteDelete rows: " & deleted)
            End Using
        End Using
    End Sub
End Module
Before Add: Detached
After Add:  Added
After Save: Unchanged, Id=1
After edit: Modified
AuthorEntity {Id: 1} Unchanged
BookEntity {Id: 1} Modified FK {AuthorId: 1}

Reloaded: Dune 1966 9780441172719
ExecuteUpdate rows: 1
Year now: 1967
ExecuteDelete rows: 1

ExecuteUpdate and ExecuteDelete run one SQL statement without loading entities (bulk operations). In Visual Basic, ExecuteUpdate(Function(setters) setters.SetProperty(Function(b) b.Year, Function(b) b.Year + 1)) compiled and ran; the SetProperty lambdas are ordinary expression lambdas, so the code reads like C#'s. Lifetime rules are the same as in C#: one DbContext per unit of work, short-lived, not thread-safe, never a static or a field of a long-lived object. AsNoTracking() is the right default for read-only queries.

Queries: query syntax, method syntax and what does not translate

Both LINQ syntaxes work, including the Visual Basic query operators Group By ... Into, Aggregate and Take/Skip. The following program prints the SQL that EF Core generated for a Group By written in query syntax and then runs several queries, including two that use a Like:

Option Strict On

Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core

Module Queries
    Sub Main()
        Using connection = OpenDatabase()
            Using context As New ShelfContext(CreateOptions(connection))
                Dim herbert = context.Authors.Single(Function(a) a.Id = 1)
                Dim gibson = context.Authors.Single(Function(a) a.Id = 2)
                context.Books.AddRange(
                    New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .Author = herbert},
                    New BookEntity With {.Title = "Dune Messiah", .Year = 1969, .Isbn = Isbn.Parse("9780593098233"), .Author = herbert},
                    New BookEntity With {.Title = "Neuromancer", .Year = 1984, .Isbn = Isbn.Parse("9780441569595"), .Author = gibson},
                    New BookEntity With {.Title = "Count Zero", .Year = 1986, .Isbn = Isbn.Parse("9780441117734"), .Author = gibson})
                context.SaveChanges()
            End Using

            Using context As New ShelfContext(CreateOptions(connection))
                Console.WriteLine("--- query syntax with Group By")
                Dim perAuthor = From b In context.Books
                                Group By Name = b.Author.Name Into Count(), Latest = Max(b.Year)
                                Order By Name
                Console.WriteLine(perAuthor.ToQueryString())
                For Each row In perAuthor
                    Console.WriteLine($"{row.Name}: {row.Count} book(s), latest {row.Latest}")
                Next

                Console.WriteLine("--- Aggregate")
                Dim total = Aggregate b In context.Books Into Sum(b.Year)
                Console.WriteLine("Sum of years: " & total)

                Console.WriteLine("--- method syntax with paging")
                Dim page = context.Books.OrderBy(Function(b) b.Title).Skip(1).Take(2).Select(Function(b) b.Title).ToList()
                Console.WriteLine(String.Join(", ", page))

                Console.WriteLine("--- Include")
                Dim authors = context.Authors.Include(Function(a) a.Books).OrderBy(Function(a) a.Name).ToList()
                For Each a In authors
                    Console.WriteLine(a.Name & ": " & String.Join(", ", a.Books.Select(Function(b) b.Title)))
                Next

                Console.WriteLine("--- EF.Functions.Like")
                Dim likeResult = context.Books.Where(Function(b) EF.Functions.Like(b.Title, "Dune%")).Count()
                Console.WriteLine("Titles like Dune%: " & likeResult)

                Console.WriteLine("--- the Visual Basic Like operator")
                Try
                    Dim vbLike = context.Books.Where(Function(b) b.Title Like "Dune*").Count()
                    Console.WriteLine(vbLike)
                Catch ex As InvalidOperationException
                    Dim start = ex.Message.IndexOf("Translation of")
                    Dim finish = ex.Message.IndexOf(" If this method", start)
                    Console.WriteLine(ex.GetType().Name & ": " & ex.Message.Substring(start, finish - start))
                End Try
            End Using
        End Using
    End Sub
End Module
--- query syntax with Group By
SELECT "a"."Name", COUNT(*) AS "Count", MAX("b"."Year") AS "Latest"
FROM "Books" AS "b"
INNER JOIN "Authors" AS "a" ON "b"."AuthorId" = "a"."Id"
GROUP BY "a"."Name"
ORDER BY "a"."Name"
Frank Herbert: 2 book(s), latest 1969
William Gibson: 2 book(s), latest 1986
--- Aggregate
Sum of years: 7904
--- method syntax with paging
Dune, Dune Messiah
--- Include
Frank Herbert: Dune, Dune Messiah
William Gibson: Neuromancer, Count Zero
--- EF.Functions.Like
Titles like Dune%: 2
--- the Visual Basic Like operator
InvalidOperationException: Translation of method 'Microsoft.VisualBasic.CompilerServices.LikeOperator.LikeString' failed.

The last block is the most important Visual Basic fact in this section. The Visual Basic Like operator is not a method call that EF Core recognises; it compiles to LikeOperator.LikeString, and the translator reports InvalidOperationException ("The LINQ expression …​ could not be translated"). Use EF.Functions.Like(b.Title, "Dune%"), whose pattern is SQL’s (% and _), not Visual Basic’s (*, ?, #).

Option Compare Text breaks translation

String comparison with = in Visual Basic obeys the file’s Option Compare setting. With the default, Option Compare Binary, b.Title = "Dune" becomes an ordinary equality that EF Core translates. With Option Compare Text, the compiler emits a call to a runtime comparison method instead, and the translator fails:

Option Compare Text
Option Strict On

Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core

Module CompareText
    Sub Main()
        Using connection = OpenDatabase()
            Using context As New ShelfContext(CreateOptions(connection))
                context.Books.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
                context.SaveChanges()

                Console.WriteLine("In memory, Option Compare Text: " & ("dune" = "DUNE"))
                Try
                    Dim count = context.Books.Where(Function(b) b.Title = "dune").Count()
                    Console.WriteLine("Rows: " & count)
                Catch ex As InvalidOperationException
                    Dim start = ex.Message.IndexOf("Translation of")
                    Dim finish = ex.Message.IndexOf(" If this method", start)
                    Console.WriteLine(ex.Message.Substring(start, finish - start))
                End Try
                Console.WriteLine("Rows with EF.Functions.Like: " & context.Books.Where(Function(b) EF.Functions.Like(b.Title, "dune")).Count())
            End Using
        End Using
    End Sub
End Module
In memory, Option Compare Text: True
Translation of method 'string.Compare' failed.
Rows with EF.Functions.Like: 1

The in-memory comparison "dune" = "DUNE" is True under Option Compare Text, and the identical-looking expression inside the query tree is a different one. Two lessons: keep Option Compare Binary (the SDK default) in files that contain EF queries, and decide case sensitivity in the database (collation) or with EF.Functions.Like; see Option Statements. String.Equals(a, b, StringComparison.OrdinalIgnoreCase) is not translated by EF Core either (the first version of this program tried it and got an InvalidOperationException).

Async, transactions, concurrency and raw SQL

This program was run. It saves and queries asynchronously, rolls back an explicit transaction, provokes an optimistic-concurrency conflict with two contexts, and uses interpolated raw SQL:

Option Strict On

Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.ChangeTracking
Imports Shelf.Core

Module Advanced
    Sub Main()
        RunAsync().GetAwaiter().GetResult()
    End Sub

    Private Async Function RunAsync() As Task
        Using connection = OpenDatabase()
            Using context As New ShelfContext(CreateOptions(connection))
                Dim options = CreateOptions(connection)

                context.Books.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
                Console.WriteLine("Saved: " & Await context.SaveChangesAsync())
                Dim titles = Await context.Books.Select(Function(b) b.Title).ToListAsync()
                Console.WriteLine("Titles: " & String.Join(", ", titles))

                Console.WriteLine("--- explicit transaction")
                Dim transaction = Await context.Database.BeginTransactionAsync()
                Try
                    context.Books.Add(New BookEntity With {.Title = "Temporary", .Year = 2000, .Isbn = Isbn.Parse("9780441569595"), .AuthorId = 2})
                    Await context.SaveChangesAsync()
                    Console.WriteLine("Inside transaction: " & Await context.Books.CountAsync())
                    Await transaction.RollbackAsync()
                Finally
                    transaction.Dispose()
                End Try
                context.ChangeTracker.Clear()
                Console.WriteLine("After rollback: " & Await context.Books.CountAsync())

                Console.WriteLine("--- optimistic concurrency")
                Using first As New ShelfContext(options), second As New ShelfContext(options)
                    Dim a = Await first.Books.FirstAsync()
                    Dim b = Await second.Books.FirstAsync()
                    a.Title = "Dune (edited by first)"
                    a.Version += 1
                    Await first.SaveChangesAsync()
                    b.Title = "Dune (edited by second)"
                    b.Version += 1
                    Dim conflict As EntityEntry = Nothing
                    Try
                        Await second.SaveChangesAsync()
                    Catch ex As DbUpdateConcurrencyException
                        Console.WriteLine("DbUpdateConcurrencyException, entries: " & ex.Entries.Count)
                        conflict = ex.Entries(0)
                    End Try
                    If conflict IsNot Nothing Then
                        Dim stored = Await conflict.GetDatabaseValuesAsync()
                        Console.WriteLine("Database has: " & CStr(stored(NameOf(BookEntity.Title))))
                    End If
                End Using

                Console.WriteLine("--- raw SQL")
                Dim year = 1960
                Dim raw = Await context.Books.FromSql($"SELECT * FROM Books WHERE Year > {year}").ToListAsync()
                Console.WriteLine("FromSql rows: " & raw.Count)
                Dim affected = Await context.Database.ExecuteSqlAsync($"UPDATE Books SET Year = Year + 1 WHERE Id = {raw(0).Id}")
                Console.WriteLine("ExecuteSql affected: " & affected)
            End Using
        End Using
    End Function
End Module
Saved: 1
Titles: Dune
--- explicit transaction
Inside transaction: 2
After rollback: 1
--- optimistic concurrency
DbUpdateConcurrencyException, entries: 1
Database has: Dune (edited by first)
--- raw SQL
FromSql rows: 1
ExecuteSql affected: 1

Visual Basic points in this listing:

  • The conflict handler stores the entry in a variable and awaits after the Try block; Await inside the Catch is the BC36943 error shown earlier.

  • The concurrency token here is an integer Version that the code increments, configured with IsConcurrencyToken(). SQL Server’s rowversion column (IsRowVersion()) updates itself; SQLite has no such type.

  • FromSql($"... {year}") takes a formattable string. Visual Basic converts an interpolated string literal to FormattableString when the parameter has that type, so each {...} hole becomes a database parameter, not text. It is safe because of that conversion; FromSqlRaw("... " & year) with a concatenated string is the unsafe form. Do not assign an interpolated string to a String variable first and pass that: the holes are formatted into the text and the protection is gone.

  • Using first As New ShelfContext(options), second As New ShelfContext(options) declares two resources in one Using.

Seeding

Two kinds. HasData in OnModelCreating (used in the model above) seeds rows with fixed keys and is part of the model, so it is included in migrations. UseSeeding (EF Core 9 and later) runs code when the database is created or migrated, which suits data that is computed or that needs the full context. This program ran:

Option Strict On

Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Shelf.Core

Module Seeding
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Dim builder As New DbContextOptionsBuilder(Of ShelfContext)()
            builder.UseSqlite(connection)
            builder.UseSeeding(
                Sub(context, storeCreated)
                    Dim set1 = context.Set(Of BookEntity)()
                    If Not set1.Any(Function(b) b.Title = "Dune") Then
                        set1.Add(New BookEntity With {.Title = "Dune", .Year = 1965, .Isbn = Isbn.Parse("9780441172719"), .AuthorId = 1})
                        context.SaveChanges()
                        Console.WriteLine("UseSeeding added Dune (store created by this call: " & storeCreated & ")")
                    End If
                End Sub)

            Using context As New ShelfContext(builder.Options)
                context.Database.EnsureCreated()
                Console.WriteLine("Books: " & context.Books.Count())
            End Using
        End Using
    End Sub
End Module
UseSeeding added Dune (store created by this call: True)
Books: 1

Source: Data seeding

The seeding delegate is a Sub(context, storeCreated) lambda; the second parameter says whether the call created the store. UseAsyncSeeding is the asynchronous counterpart, and both should be written so that running twice does not duplicate rows (the Any check above).

Migrations and Visual Basic

Migrations are where Visual Basic stops working like C#. dotnet ef migrations add generates the migration as source code in the project’s language, and EF Core ships a code generator for C# only. Run against a Visual Basic project, the tool says so:

$ dotnet ef migrations add Initial
Build started...
Build succeeded.
The project language 'VB' isn't supported by the built-in IMigrationsCodeGenerator service. You can try looking for an additional NuGet package which supports this language; moving your DbContext type to a C# class library referenced by this project; or manually implementing and registering the design-time service for the programming language.

(The message is the tool’s own; it names the three ways out.) The options, from simplest to most flexible:

Option Notes

Keep the migrations in a small C# project

The supported route, and the one this page tested. The model (DbContext and entities) stays in Visual Basic; a C# class library holds the generated migrations; the application and the tooling reference both.

EFCore.VisualBasic (community)

A package that adds a Visual Basic code generator to dotnet ef design time. As checked on 2026-10-19, the newest release on NuGet is 8.0.0 (for EF Core 8; earlier ones 7.0.0 and 6.x previews). See the test below: it does not work with EF Core 10.

Hand-written migrations

A migration is a class that derives from Migration with Up and Down methods that call MigrationBuilder; it can be written in Visual Basic, and the ModelSnapshot has to be kept in step by hand, which is the part nobody wants.

EnsureCreated

Creates the schema from the model without migrations. Right for tests and prototypes; it cannot evolve a schema and cannot be mixed with migrations on the same database.

SQL scripts

dotnet ef migrations script produces plain SQL from the C# migrations, to apply with the database’s own tools; or maintain the schema with a dedicated tool outside EF.

The C# migrations project, tested

The layout is three projects: MigData (Visual Basic class library, the model from above), Shelf.Migrations (C# class library that references MigData; migrations go here) and MigApp (the Visual Basic executable that references both, contains the design-time factory and carries the Microsoft.EntityFrameworkCore.Design package). The C# project:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <NuGetAudit>false</NuGetAudit>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\MigData\MigData.vbproj" />
  </ItemGroup>
</Project>

The start-up project registers the migrations assembly and provides the design-time factory (the tool uses it because the executable has no generic host):

Option Strict On

Imports Microsoft.Data.Sqlite
Imports Microsoft.EntityFrameworkCore
Imports Microsoft.EntityFrameworkCore.Design

Public Class ShelfContextFactory
    Implements IDesignTimeDbContextFactory(Of ShelfContext)

    Public Function CreateDbContext(args As String()) As ShelfContext Implements IDesignTimeDbContextFactory(Of ShelfContext).CreateDbContext
        Return New ShelfContext(CreateOptions("Data Source=shelf.db"))
    End Function

    Public Shared Function CreateOptions(connectionString As String) As DbContextOptions(Of ShelfContext)
        Return New DbContextOptionsBuilder(Of ShelfContext)().
            UseSqlite(connectionString, Sub(o) o.MigrationsAssembly("Shelf.Migrations")).Options
    End Function
End Class

Module Program
    Sub Main()
        Using connection As New SqliteConnection("Data Source=:memory:")
            connection.Open()
            Dim options = New DbContextOptionsBuilder(Of ShelfContext)().
                UseSqlite(connection, Sub(o) o.MigrationsAssembly("Shelf.Migrations")).Options
            Using context As New ShelfContext(options)
                Console.WriteLine("Pending before: " & String.Join(", ", context.Database.GetPendingMigrations().Select(Function(m) m.Substring(m.IndexOf("_"c) + 1))))
                context.Database.Migrate()
                Console.WriteLine("Applied after:  " & String.Join(", ", context.Database.GetAppliedMigrations().Select(Function(m) m.Substring(m.IndexOf("_"c) + 1))))
                Console.WriteLine("Authors seeded: " & context.Authors.Count())
            End Using
        End Using
    End Sub
End Module

The tool is installed once, then the command names the migrations project and the start-up project:

$ dotnet ef migrations add Initial --project MigCs --startup-project MigApp
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'

$ ls MigCs/Migrations
20261019215308_Initial.Designer.cs
20261019215308_Initial.cs
ShelfContextModelSnapshot.cs

The generated migration is C#, in the C# project, and is not edited by hand (excerpt):

using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional

namespace Shelf.Migrations.Migrations
{
    /// <inheritdoc />
    public partial class Initial : Migration
    {
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            migrationBuilder.CreateTable(
                name: "Authors",
                columns: table => new
                {
                    Id = table.Column<int>(type: "INTEGER", nullable: false)
                        .Annotation("Sqlite:Autoincrement", true),
                    Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false)
                },
                constraints: table =>
                {
                    table.PrimaryKey("PK_Authors", x => x.Id);
                });

            migrationBuilder.CreateTable(

The Visual Basic program above then ran Migrate() against an in-memory SQLite database. The output includes the two seed rows from HasData, which came through the migration:

Pending before: Initial
Applied after:  Initial
Authors seeded: 2

dotnet ef migrations script --startup-project ../MigApp printed the SQL (first lines):

CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
    "MigrationId" TEXT NOT NULL CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY,
    "ProductVersion" TEXT NOT NULL
);

BEGIN TRANSACTION;
CREATE TABLE "Authors" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Authors" PRIMARY KEY AUTOINCREMENT,
    "Name" TEXT NOT NULL
);

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Year" INTEGER NOT NULL,
    "Isbn" TEXT NOT NULL,
    "Version" INTEGER NOT NULL,
    "AuthorId" INTEGER NOT NULL,
    CONSTRAINT "FK_Books_Authors_AuthorId" FOREIGN KEY ("AuthorId") REFERENCES "Authors" ("Id") ON DELETE CASCADE
);

INSERT INTO "Authors" ("Id", "Name")
VALUES (1, 'Frank Herbert');
SELECT changes();

INSERT INTO "Authors" ("Id", "Name")
VALUES (2, 'William Gibson');
SELECT changes();

The same documentation lists the other ways to apply migrations (a script, a bundle, Migrate() at start-up); Migrate() at start-up is fine for development and a single instance, while a deployment step is better for anything with more than one instance.

EFCore.VisualBasic, tested

EFCore.VisualBasic is a community project, published by an EF team member on the project’s own repository, that registers a Visual Basic migrations generator through IDesignTimeServices. The test installed EntityFrameworkCore.VisualBasic 8.0.0 next to EF Core 10.0.13. Restore warns first:

warning NU1608: Detected package version outside of dependency constraint: EntityFrameworkCore.VisualBasic 8.0.0 requires Microsoft.EntityFrameworkCore.Design (>= 8.0.2 && < 9.0.0) but version Microsoft.EntityFrameworkCore.Design 10.0.13 was resolved.
warning NU1608: Detected package version outside of dependency constraint: Microsoft.CodeAnalysis.VisualBasic 4.5.0 requires Microsoft.CodeAnalysis.Common (= 4.5.0) but version Microsoft.CodeAnalysis.Common 5.0.0 was resolved.

With warnings as errors off, the project builds and the services class (EFCoreVisualBasicServices.ConfigureDesignTimeServices) compiles, but running the tool fails inside Roslyn:

$ dotnet ef migrations add Initial --no-build
Method 'GetForEachSymbols' in type 'Microsoft.CodeAnalysis.VisualBasic.Utilities.SpeculationAnalyzer' from assembly 'Microsoft.CodeAnalysis.VisualBasic.Workspaces, Version=4.5.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35' does not have an implementation.
Could not load type 'Microsoft.CodeAnalysis.LanguageService.AbstractBlockFacts' from assembly 'Microsoft.CodeAnalysis.Workspaces, Version=5.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'.

So with EF Core 10 the C# migrations project is the working route. If the project publishes a version for the EF Core release you use, the package would remove the need for the second project; re-check its NuGet page before relying on either statement.

Source: Reverse engineering (scaffolding) describes the plug-in model that community code generators use, and the package page (community) lists its versions.

Reverse engineering (dotnet ef dbcontext scaffold) has the same limit: it writes C# entity classes. Scaffold into a C# project and reference it, or write the entities by hand.

Binding query results to Windows Forms and WPF

For a grid in Windows Forms the shape is: load into a list, wrap it in a BindingList(Of T) or a BindingSource, set DataGridView.DataSource. EF Core can also provide a ready local view (context.Books.Local.ToBindingList()), which stays in step with the change tracker. The listing below was built, not run (Windows Forms needs Windows); the Async Sub override is the correct place to start asynchronous loading because it runs on the UI thread and resumes there.

Option Strict On

Imports System.ComponentModel
Imports System.Windows.Forms
Imports Microsoft.EntityFrameworkCore

Public Class BookRow
    Public Property Id As Integer
    Public Property Title As String = ""
    Public Property Year As Integer
End Class

Public Class RowsContext
    Inherits DbContext

    Public Property Books As DbSet(Of BookRow)

    Protected Overrides Sub OnConfiguring(optionsBuilder As DbContextOptionsBuilder)
        optionsBuilder.UseSqlite("Data Source=shelf.db")
    End Sub
End Class

Public Class BookGridForm
    Inherits Form

    Private ReadOnly _grid As New DataGridView() With {.Dock = DockStyle.Fill, .AutoGenerateColumns = True}
    Private ReadOnly _source As New BindingSource()
    Private ReadOnly _rows As New BindingList(Of BookRow)

    Public Sub New()
        Text = "Shelf"
        _source.DataSource = _rows
        _grid.DataSource = _source
        Controls.Add(_grid)
    End Sub

    Public Shared Function TrackedRows(context As RowsContext) As BindingList(Of BookRow)
        ' A binding list that stays in step with the change tracker.
        Return context.Books.Local.ToBindingList()
    End Function

    Protected Overrides Async Sub OnShown(e As EventArgs)
        MyBase.OnShown(e)
        Using context As New RowsContext()
            ' AsNoTracking: the grid edits copies; save them explicitly with a separate unit of work.
            Dim loaded = Await context.Books.AsNoTracking().OrderBy(Function(b) b.Title).ToListAsync()
            _rows.RaiseListChangedEvents = False
            For Each row In loaded
                _rows.Add(row)
            Next
            _rows.RaiseListChangedEvents = True
            _rows.ResetBindings()
        End Using
    End Sub
End Class

Public Module Program
    <STAThread>
    Public Sub Main()
        Application.EnableVisualStyles()
        Application.Run(New BookGridForm())
    End Sub
End Module

Notes: the entity here is a row class for the grid, with settable properties (a grid cannot edit read-only ones); the context loads with AsNoTracking because the grid edits copies and the program saves them explicitly; RaiseListChangedEvents = False while adding many rows avoids one grid refresh per row; do not keep the context alive for the lifetime of the form. For WPF the equivalent is an ObservableCollection(Of BookViewModel) that the view model fills after Await returns (see WPF Applications), and a DataTable.DefaultView binds to a DataGrid in both frameworks. The Windows Forms details (BindingSource, DataGridView columns) are on Windows Forms Applications.

Other stores: MongoDB and Redis

Document and key-value stores have their own drivers, which are .NET libraries and therefore work from Visual Basic. The listing was built, not run: it needs servers on localhost. It uses MongoDB.Driver 3.12.0 and StackExchange.Redis 3.4.5 with expression lambdas in the queries (the Mongo Find takes an expression tree, as LINQ does):

Option Strict On

Imports MongoDB.Bson
Imports MongoDB.Driver
Imports StackExchange.Redis

' Built, not run: both stores need a running server.
Public Class BookDocument
    Public Property Id As ObjectId
    Public Property Title As String = ""
    Public Property Year As Integer
End Class

Module Program
    Sub Main()
        RunAsync().GetAwaiter().GetResult()
    End Sub

    Private Async Function RunAsync() As Task
        Dim client As New MongoClient("mongodb://localhost:27017")
        Dim books = client.GetDatabase("shelf").GetCollection(Of BookDocument)("books")
        Await books.InsertOneAsync(New BookDocument With {.Title = "Dune", .Year = 1965})
        Dim newer = Await books.Find(Function(b) b.Year > 1960).SortBy(Function(b) b.Title).ToListAsync()
        Console.WriteLine("Mongo documents: " & newer.Count)

        Dim redis = Await ConnectionMultiplexer.ConnectAsync("localhost:6379")
        Dim db = redis.GetDatabase()
        Await db.StringSetAsync("book:1:title", "Dune", TimeSpan.FromMinutes(5))
        Dim cached As RedisValue = Await db.StringGetAsync("book:1:title")
        Console.WriteLine("Redis value: " & cached.ToString())
    End Function
End Module

The data models, indexing and operations of these stores are explained with their native tools in MongoDB drivers and tooling and Redis clients and client-side caching; Choosing the right database helps to decide which store a feature needs.

Legacy: EF6, LINQ to SQL and typed DataSets

  • EF6 (EntityFramework 6.x, the System.Data.Entity namespace) is in maintenance. The 6.5 line targets .NET and .NET Framework: a project test on .NET 10 restored EntityFramework 6.5.1, compiled a DbContext-derived class, and loaded the assembly at run time (EF6 type loaded: EntityFramework 6.0.0.0, Runtime: .NET 10.0.13). The test did not open a database; the EDMX designer and the Package Manager Console commands of EF6 (Enable-Migrations, Update-Database) were not exercised. For an application that already uses EF6, keeping it on .NET Framework is legitimate; moving to .NET 10 is a port to EF Core in practice, covered for C# on Data Access with EF6. The official guide for the move is Port from EF6 to EF Core.

  • LINQ to SQL (System.Data.Linq, .dbml designer files) is documented in the .NET Framework data documentation (the link below sits under /dotnet/framework/), not in the .NET documentation. Plan to port a program that uses it to EF Core or Dapper as part of a move to .NET 10.

  • Typed DataSets generated by older Visual Studio wizards are plain code on top of System.Data and port easily, but they are not a model to start from.

  • System.Data.OracleClient and System.Data.SqlClient are the old provider names; see the opening table.

Visual Basic and C# in data access

Topic Visual Basic C#

Dispose a connection

Using connection As New SqlConnection(text) …​ End Using

using var connection = new SqlConnection(text);

Async disposal

No Await Using; synchronous Using, or Try/Finally with Dispose

await using

Await in Catch or Finally

Not allowed (BC36943)

Allowed since C# 6

Optional column

DBNull.Value, If(...), Integer?

DBNull.Value, ??, int?

Required string properties in EF

IsRequired() or the Fluent API; no nullable reference type annotations

Non-nullable string is required when nullable reference types are on

Parameters

Add(name, type).Value = x (method-call target is legal)

Add(name, type).Value = x

Anonymous parameter object (Dapper)

New With {.minimum = 1970}

new { minimum = 1970 }

LINQ in EF

Query syntax with Group By ... Into, Aggregate; method syntax; Like operator not translated

Query syntax and method syntax

Interpolated raw SQL

FromSql($"... {x}") converts to a formattable string

FromSql($"... {x}")

Migrations scaffolding

Not for VB; use a C# migrations project

Built in

Design-time code generation (migrations, scaffolding)

Not for Visual Basic (tested for migrations)

Built in

Case-insensitive names

books hides Books; locals can hide types (directory hides Directory)

Case-sensitive: books and Books differ

Where older material is outdated

Older advice Today on .NET 10

Imports System.Data.SqlClient, SqlConnection from the System.Data.SqlClient assembly

Package Microsoft.Data.SqlClient, namespace Microsoft.Data.SqlClient; encryption on by default.

System.Data.OracleClient

Removed; use Oracle.ManagedDataAccess.Core.

LINQ to SQL and EF6 (System.Data.Entity, automatic migrations, Enable-Migrations)

EF Core with dotnet ef migrations add (from a C# migrations project for Visual Basic).

System.Runtime.Caching.MemoryCache

Microsoft.Extensions.Caching.Memory.

Connection strings in App.config read with ConfigurationManager

Microsoft.Extensions.Configuration with JSON, environment variables and secret stores; My.Settings for user preferences only.

Concatenated SQL in teaching examples

Never; parameters, always.

Entity Framework through the Package Manager Console (Update-Database)

The dotnet ef command-line tool, or Migrate()/script/bundles.

Versions, compiler messages and run-time failures

When the features on this page arrived:

Feature Introduced in

Interpolated strings, including conversion to FormattableString

Visual Basic 14 (Visual Studio 2015)

Catch ... When exception filters

Visual Basic from its first release

Await in Catch/Finally

Never supported in Visual Basic (BC36943); C# has it since C# 6

HasData seeding

EF Core 2.1

ExecuteUpdate, ExecuteDelete, FromSql (interpolated, parameterised)

EF Core 7

UseSeeding, UseAsyncSeeding

EF Core 9

Encrypt-by-default connection strings

Microsoft.Data.SqlClient 4.0

EF Core 10, the release used here

Ships with .NET 10 (package versions 10.0.x)

Compiler messages seen while writing the examples:

Code Message (as the .NET 10 SDK printed it) Cause

BC36943

'Await' cannot be used inside a 'Catch' statement, a 'Finally' statement, or a 'SyncLock' statement.

Awaiting inside an exception handler.

BC36675

Statement lambdas cannot be converted to expression trees.

A multi-line lambda passed where EF Core needs an expression (Where, Select, OrderBy).

BC30456

'CreateDirectory' is not a member of 'String'.

A local variable directory hid the Directory class.

BC36663 and BC30456

Option Strict On does not allow narrowing in implicit type conversions between method ... and 'NotifyCanExecuteChanged' is not a member of 'RelayCommand'.

Two classes of the same name, one in your namespace and one imported (shown on the WPF page).

Run-time failures seen, with the exception type and the message the provider gave:

Situation Exception Message (shortened)

Command on a closed connection

InvalidOperationException

ExecuteScalar can only be called when the connection is open.

Parameter value left as Nothing

InvalidOperationException

Value must be set.

Unique constraint violated

SqliteException (error 19, extended 2067)

UNIQUE constraint failed: Books.Isbn

Typed getter on a NULL column

InvalidOperationException

The data is NULL at ordinal 0. This method can't be called on NULL values. Check using IsDBNull before calling.

CHECK constraint violated in a transaction

SqliteException (error 19)

CHECK constraint failed: Balance >= 0

Query EF Core cannot translate

InvalidOperationException

The LINQ expression ... could not be translated.

Two contexts update the same row

DbUpdateConcurrencyException

Raised by SaveChanges of the second context.

SQL Server unreachable

SqlException (Number 0)

A network-related or instance-specific error occurred while establishing a connection to SQL Server.

Cancelled token

TaskCanceledException

Derives from OperationCanceledException.

Pitfalls

Pitfall Remedy

Nothing as a parameter value

DBNull.Value.

String-concatenated SQL

Parameters; allow-list identifiers.

AddWithValue with strings against SQL Server

Add(name, SqlDbType.NVarChar, size).Value.

Await in Catch or Finally

Capture, leave the block, then await (BC36943).

Visual Basic Like in a LINQ query

EF.Functions.Like.

Option Compare Text in a file with EF queries

Option Compare Binary; collation in the database.

String properties are nullable in EF Core

IsRequired().

Local variable named like a type or a DbSet

Rename; names are case-insensitive.

Trying dotnet ef migrations add in a VB project

Use a C# migrations project.

One shared DbContext for the whole application

One per unit of work.

Leaked connection

Using everywhere; do not store connections in fields.

TrustServerCertificate=True to silence a certificate error

Fix the certificate; use the flag only for throw-away local servers.

Connection string in source control

Configuration, user secrets, a key vault or integrated security.

Unbounded query bound to a grid

Page in the query (Skip/Take); see pagination strategies.