To call a stored procedure from ASP.NET or VB.NET, open a SqlConnection, create a SqlCommand with the procedure name, set CommandType = CommandType.StoredProcedure, add typed parameters with Parameters.Add() (not AddWithValue), then run ExecuteNonQuery for output values, ExecuteReader for result sets, or ExecuteXmlReader for FOR XML output. Wrap the connection and command in Using blocks so they’re disposed on every path. The examples below use Microsoft.Data.SqlClient and work on .NET Framework 4.6.2+ and modern .NET.

We first published this article for .NET 2.0 and Visual Studio 2005, and people still land on it every week. The advice underneath hasn’t changed much: put the SQL in the database, call it with typed parameters, read the results with the cheapest reader that fits. The code around it has changed a lot. The original samples had MsgBox calls in a web article, a WHERE clause before the FROM, and a SqlCommand that was never declared (we know, we know). This rewrite keeps the same three examples, fixes the code, and adds a section on the thing everybody asks about now: getting an AI coding agent to write the procedure for you without it quietly writing something slow.

Everything below is VB.NET, because that’s what the URL promises and what most readers arrive for. Where the C# is different enough to matter, there’s a C# block too. If you’re on ASP.NET Core, your web project is almost certainly C# (Microsoft never shipped VB templates for it), but the data-access code still compiles fine in a VB.NET class library if you’d rather not port it.

Write the procedure first

The original article spent nine numbered steps on how to create a procedure from Visual Studio’s Server Explorer. That path still exists (it’s called SQL Server Object Explorer now), but almost nobody writes procedures there. Use SQL Server Management Studio, or VS Code with the MSSQL extension, and keep the .sql file in your repository. A procedure that only exists inside the database is a procedure nobody can review or roll back.

Here’s the procedure the first example calls. Two inputs, one output, and it returns the identity of the row it inserted.

CREATE OR ALTER PROCEDURE dbo.SendMessage
    @Mobile    VARCHAR(15),
    @Message   NVARCHAR(160),
    @LastMsgID BIGINT OUTPUT
AS
BEGIN
    SET NOCOUNT ON;

    INSERT INTO dbo.Messages (Mobile, MessageText, SentOn)
    VALUES (@Mobile, @Message, SYSUTCDATETIME());

    SET @LastMsgID = SCOPE_IDENTITY();
END

 

Two habits worth keeping from this. SET NOCOUNT ON stops SQL Server sending “1 row affected” messages back to the client, which is noise for a program and, in a loop, measurable overhead. And SCOPE_IDENTITY() rather than @@IDENTITY, because @@IDENTITY will happily return an ID from a trigger on a completely different table. That one has bitten more than one developer here.

Tip that survived from the original: a stored procedure can take at most 2,100 parameters. If you’re anywhere near that, pass a table-valued parameter or JSON instead.

Connection and command setup

Two things changed since the .NET 2.0 days. The namespace is now Microsoft.Data.SqlClient (a NuGet package) rather than System.Data.SqlClient; the API is the same, only the Imports line differs. And since version 4 of that package, connections are encrypted by default, so a local SQL Server with a self-signed certificate will refuse to connect until you add TrustServerCertificate=True to the connection string. Do that in development only. On production, install a real certificate.

Connection strings belong in configuration (appsettings.json on Core, web.config on Framework), never in code. The original article had User ID=User;Password=Password inline. Please don’t.

' ASP.NET Core
Dim connectionString = builder.Configuration.GetConnectionString("Default")

' ASP.NET Framework
Dim connectionString = ConfigurationManager.ConnectionStrings("Default").ConnectionString

 

Every example below follows the same shape: a Using block for the connection, a Using block for the command, open the connection as late as possible, and let the End Using close it. Connection pooling in SqlClient means the open/close is cheap. Holding a connection open across a request because “opening is expensive” is a 2005 habit that starves the pool in 2026.

The way not to do it

The old article showed this as “the first method” and then said it wasn’t recommended. We’ll be blunter: don’t do it.

' String-built EXEC. Injectable, and SQL Server can't reuse the plan.
cmd.CommandText = "EXEC dbo.SendMessage '" & mobile & "', '" & message & "'"

 

If message contains a single quote, this breaks. If it contains '; DROP TABLE dbo.Messages; --, it does worse. And even when the input is safe, every distinct string is a new query plan for SQL Server to compile and cache. Typed parameters fix all three problems in one line, so there’s no scenario where the string version wins.

Example 1: output parameter and return value

This calls dbo.SendMessage, reads the new ID back through the output parameter, and returns it. Note the parameter types and sizes match the procedure’s declaration exactly. That isn’t fussiness. If you pass an NVarChar parameter to a VARCHAR column, SQL Server has to convert the column side of the comparison and will scan the index instead of seeking it.

Imports System.Data
Imports Microsoft.Data.SqlClient

Public Class MessageRepository
    Private ReadOnly _connectionString As String

    Public Sub New(connectionString As String)
        _connectionString = connectionString
    End Sub

    Public Function SendMessage(mobile As String, message As String) As Long
        Using con As New SqlConnection(_connectionString)
            Using cmd As New SqlCommand("dbo.SendMessage", con)
                cmd.CommandType = CommandType.StoredProcedure

                cmd.Parameters.Add("@Mobile", SqlDbType.VarChar, 15).Value = mobile
                cmd.Parameters.Add("@Message", SqlDbType.NVarChar, 160).Value = message

                Dim idParam = cmd.Parameters.Add("@LastMsgID", SqlDbType.BigInt)
                idParam.Direction = ParameterDirection.Output

                con.Open()
                cmd.ExecuteNonQuery()

                Return CLng(idParam.Value)
            End Using
        End Using
    End Function
End Class

 

The same thing in C#, since this is the block people copy most:

using System.Data;
using Microsoft.Data.SqlClient;

public long SendMessage(string mobile, string message)
{
    using var con = new SqlConnection(_connectionString);
    using var cmd = new SqlCommand("dbo.SendMessage", con)
    {
        CommandType = CommandType.StoredProcedure
    };

    cmd.Parameters.Add("@Mobile", SqlDbType.VarChar, 15).Value = mobile;
    cmd.Parameters.Add("@Message", SqlDbType.NVarChar, 160).Value = message;

    var idParam = cmd.Parameters.Add("@LastMsgID", SqlDbType.BigInt);
    idParam.Direction = ParameterDirection.Output;

    con.Open();
    cmd.ExecuteNonQuery();

    return (long)idParam.Value;
}

 

Output parameters are populated after ExecuteNonQuery completes. If you use ExecuteReader instead, they’re not available until the reader is closed, which catches people out when they read the ID before the End Using.

A procedure can also send back an integer through RETURN, separate from any output parameters. That’s the usual place for a status code. To read it, add a parameter with direction ReturnValue; the name doesn’t matter.

Dim rv = cmd.Parameters.Add("@ReturnValue", SqlDbType.Int)
rv.Direction = ParameterDirection.ReturnValue

con.Open()
cmd.ExecuteNonQuery()

Dim status = CInt(rv.Value)   ' whatever the procedure put in RETURN

 

And if the procedure ends with a single-row, single-column SELECT instead of an output parameter, ExecuteScalar reads that first cell directly. Pick one convention per codebase. Mixing output parameters, return values, and scalar selects for the same job is how a team ends up with three ways to get an ID and none of them documented.

A note on AddWithValue. It works, and you’ll see it everywhere. It infers the SQL type from the .NET type, which means every String becomes NVARCHAR at whatever length the value happens to be. Against a VARCHAR column that’s a silent index scan, and every different length is a different cached plan. The three extra characters in Parameters.Add("@x", SqlDbType.VarChar, 15) are cheaper than the support ticket.

Example 2: result set into a list or DataTable

The procedure:

CREATE OR ALTER PROCEDURE dbo.GetAuthors
    @AuthorName NVARCHAR(100)
AS
BEGIN
    SET NOCOUNT ON;

    SELECT Author_ID, Author_Name, Author_Location
    FROM dbo.Authors
    WHERE Author_Name LIKE @AuthorName
    ORDER BY Author_Name;
END

 

The original filled a DataTable by hand with an object array. If you want a DataTable, table.Load(reader) does it in one line. But most code written today wants a typed list, so that’s the main example. Read the column ordinals once, outside the loop, and check for DBNull on nullable columns before calling the typed getter. GetString on a NULL throws.

Public Class Author
    Public Property Id As Integer
    Public Property Name As String
    Public Property Location As String
End Class

Public Function GetAuthors(namePattern As String) As List(Of Author)
    Dim authors As New List(Of Author)

    Using con As New SqlConnection(_connectionString)
        Using cmd As New SqlCommand("dbo.GetAuthors", con)
            cmd.CommandType = CommandType.StoredProcedure
            cmd.Parameters.Add("@AuthorName", SqlDbType.NVarChar, 100).Value = namePattern

            con.Open()
            Using reader = cmd.ExecuteReader()
                Dim idOrd = reader.GetOrdinal("Author_ID")
                Dim nameOrd = reader.GetOrdinal("Author_Name")
                Dim locOrd = reader.GetOrdinal("Author_Location")

                While reader.Read()
                    authors.Add(New Author With {
                        .Id = reader.GetInt32(idOrd),
                        .Name = reader.GetString(nameOrd),
                        .Location = If(reader.IsDBNull(locOrd), Nothing, reader.GetString(locOrd))
                    })
                End While
            End Using
        End Using
    End Using

    Return authors
End Function

 

If a DataTable is what the calling code expects (a GridView on Web Forms, say), replace the loop with this:

Using reader = cmd.ExecuteReader()
    Dim table As New DataTable()
    table.Load(reader)
    Return table
End Using

 

Pass the wildcard from the caller (GetAuthors("Y%")), not from inside the procedure. A procedure that appends % itself can’t be used for an exact match later without a second procedure.

Example 3: XML and JSON results

The original’s third example returned XML with FOR XML AUTO. That still works, but in the rewrite we’ve switched to FOR XML PATH with a ROOT, because AUTO returns a fragment with no root element and XDocument.Load won’t accept it. PATH also gives you control over element names, which AUTO doesn’t.

CREATE OR ALTER PROCEDURE dbo.GetAuthorsXML
    @AuthorName NVARCHAR(100)
AS
BEGIN
    SET NOCOUNT ON;

    SELECT Author_ID   AS [@id],
           Author_Name AS [Name],
           Author_Location AS [Location]
    FROM dbo.Authors
    WHERE Author_Name LIKE @AuthorName
    FOR XML PATH('Author'), ROOT('Authors');
END

 

ExecuteXmlReader hands you an XmlReader positioned on the result, and XDocument.Load takes it from there. The original called ExecuteReader and then ExecuteXmlReader on the same command, which is one call too many.

Imports System.Xml.Linq

Public Function GetAuthorsXml(namePattern As String) As XDocument
    Using con As New SqlConnection(_connectionString)
        Using cmd As New SqlCommand("dbo.GetAuthorsXML", con)
            cmd.CommandType = CommandType.StoredProcedure
            cmd.Parameters.Add("@AuthorName", SqlDbType.NVarChar, 100).Value = namePattern

            con.Open()
            Using reader = cmd.ExecuteXmlReader()
                Return XDocument.Load(reader)
            End Using
        End Using
    End Using
End Function

 

If you’re starting a new project today, you probably want JSON, not XML. SQL Server 2016 and later support FOR JSON PATH, and the calling side is a plain reader. One catch: SQL Server splits long JSON output across multiple rows of about 2,000 characters each, so you have to concatenate. There’s no ExecuteJsonReader.

' Procedure ends with:  ... WHERE Author_Name LIKE @AuthorName FOR JSON PATH;

Public Function GetAuthorsJson(namePattern As String) As String
    Dim sb As New StringBuilder()

    Using con As New SqlConnection(_connectionString)
        Using cmd As New SqlCommand("dbo.GetAuthorsJSON", con)
            cmd.CommandType = CommandType.StoredProcedure
            cmd.Parameters.Add("@AuthorName", SqlDbType.NVarChar, 100).Value = namePattern

            con.Open()
            Using reader = cmd.ExecuteReader()
                While reader.Read()
                    sb.Append(reader.GetString(0))
                End While
            End Using
        End Using
    End Using

    Return sb.ToString()
End Function

 

Async and ASP.NET Core

Everything above is synchronous, which is fine on Web Forms and in a console tool. On ASP.NET Core, a synchronous database call holds a thread-pool thread for the whole round trip, and under load that’s the difference between serving 500 concurrent requests and queueing them. Every method used above has an Async twin. The shape doesn’t change; you add Await in three places.

Public Async Function GetAuthorsAsync(namePattern As String) As Task(Of List(Of Author))
    Dim authors As New List(Of Author)

    Using con As New SqlConnection(_connectionString)
        Using cmd As New SqlCommand("dbo.GetAuthors", con)
            cmd.CommandType = CommandType.StoredProcedure
            cmd.Parameters.Add("@AuthorName", SqlDbType.NVarChar, 100).Value = namePattern

            Await con.OpenAsync()
            Using reader = Await cmd.ExecuteReaderAsync()
                Dim idOrd = reader.GetOrdinal("Author_ID")
                Dim nameOrd = reader.GetOrdinal("Author_Name")
                Dim locOrd = reader.GetOrdinal("Author_Location")

                While Await reader.ReadAsync()
                    authors.Add(New Author With {
                        .Id = reader.GetInt32(idOrd),
                        .Name = reader.GetString(nameOrd),
                        .Location = If(reader.IsDBNull(locOrd), Nothing, reader.GetString(locOrd))
                    })
                End While
            End Using
        End Using
    End Using

    Return authors
End Function

 

If your project already uses Dapper or Entity Framework Core, both call procedures with less ceremony (con.QueryAsync(Of Author)("dbo.GetAuthors", New With {.AuthorName = "Y%"}, commandType:=CommandType.StoredProcedure) in Dapper’s case). This article stays on raw SqlClient because that’s what the other two run on underneath, and when either of them misbehaves, this is the layer you end up reading.

Which Execute method to use

The procedure returns Call Read the result from
Nothing, or only output parameters ExecuteNonQuery cmd.Parameters("@Name").Value after the call
A status code via RETURN ExecuteNonQuery A parameter with Direction = ReturnValue
One cell (a count, a new ID via SELECT) ExecuteScalar The return value of the call, cast to the type
Rows ExecuteReader Loop with reader.Read(), or DataTable.Load(reader)
XML via FOR XML ... ROOT ExecuteXmlReader XDocument.Load(reader)
JSON via FOR JSON ExecuteReader Concatenate reader.GetString(0) across rows

 

Writing stored procedures with an AI coding agent

A good share of the .NET work that comes through us now involves Claude Code, GitHub Copilot, or Cursor somewhere in the loop, and stored procedures are one of the better jobs to hand them. The task is small, the contract is explicit (these inputs, this output, this table), and the result is testable with a five-line script. The agent gets the T-SQL right most of the time. What it can’t do is see your data, and that’s where the problems are.

The prompt matters more than the tool. Give the agent the actual CREATE TABLE script (right-click, Script Table As, paste it), name the SQL Server version, state the contract, and ask for the test in the same request. A prompt that works for us looks like this:

Here is the DDL for dbo.Messages: (paste the CREATE TABLE script)
Target: SQL Server 2019.

Write dbo.SendMessage with inputs @Mobile VARCHAR(15), @Message NVARCHAR(160)
and output @LastMsgID BIGINT. Requirements: SET NOCOUNT ON, no dynamic SQL,
TRY/CATCH that rethrows with THROW, parameter types must match the column types
exactly. Do not alter the table.

Then write a T-SQL test script that calls it twice and checks the second
@LastMsgID is greater than the first. Save both as separate .sql files.

 

Once the procedure exists, ask the agent to generate the VB.NET or C# calling method from it in the same session. Parameter-name typos and type mismatches between the procedure and the client code are the most common bug in this whole area, and having one tool write both sides removes most of them.

Then review. The agent doesn’t know your row counts, your indexes, or which parameter values are common and which are rare, so the review is mostly about performance and safety, and it’s short:

  • Parameter types and lengths match the column types exactly (an NVARCHAR parameter against a VARCHAR column scans the index).
  • SET NOCOUNT ON is the first statement.
  • No dynamic SQL. If it’s unavoidable, it’s sp_executesql with parameters, never concatenation.
  • TRY/CATCH rethrows with THROW. Agents like to RAISERROR a friendly message and swallow the original error, which loses the line number.
  • Any transaction is committed or rolled back on every path, including the CATCH.
  • Column lists are explicit. SELECT * in a procedure breaks the calling code the day someone adds a column.
  • You’ve looked at the actual execution plan with production-sized data. The agent tested against an empty table, and everything seeks on an empty table.
  • The .sql file is committed alongside the .NET change, so the next agent session (or the next developer) can see what changed and why.

That last one is the one teams skip. An agent that edits a procedure directly in the database, with no file in the repo, has made a change nobody can diff. Keep procedures in a database project or a migrations folder, point the agent at that folder, and the whole workflow becomes reviewable in a pull request like any other code.

FAQ

What’s the difference between an output parameter and a return value?

A return value is a single integer set with RETURN n and read through a parameter with Direction = ReturnValue. It’s meant for status codes. Output parameters can be any SQL type and you can have several. Use output parameters for data (a new ID, a total) and the return value for success or failure, if you use it at all.

Should I use Microsoft.Data.SqlClient or System.Data.SqlClient?

Microsoft.Data.SqlClient for anything new. It’s the one that gets new features and security fixes, it works on .NET Framework 4.6.2+ and all modern .NET, and the API is identical. Note that it encrypts by default, so local development servers need TrustServerCertificate=True in the connection string.

Why does my output parameter come back empty?

Usually because you read it while a SqlDataReader from the same command is still open. Output parameters and the return value are only populated after the reader is closed or the command finishes. Read them after End Using on the reader, or use ExecuteNonQuery if you don’t need rows.

Is it safe to let an AI agent write stored procedures?

Safe enough, with the review above. The T-SQL it produces is usually correct. The risks are performance (it can’t see your data or plans), dynamic SQL creeping in when you didn’t ask for it, and changes made directly in the database with no file in source control. Give it the table DDL, ask for a test script with the procedure, and review the execution plan yourself.

Related reading

Need a .NET team that still reads execution plans?

We’ve been building ASP.NET applications for clients in the US, UK, and Australia since 2002, and we now run AI coding agents inside that work with human review at every merge. If you have a .NET application to build, migrate, or maintain, talk to us.

Offshore ASP.NET Development