Pass a DataTable as a table-valued parameter to a SQL Server stored procedure
Inserting 500 order lines one INSERT at a time means 500 round trips. A table-valued parameter (TVP) sends all of them in a single call, as a parameter shaped like a table. SQL Server has supported TVPs since 2008; ADO.NET sends a DataTable as one with almost no extra code.
1. Create a table type
CREATE TYPE dbo.OrderLineList AS TABLE
(
ProductId int NOT NULL,
Quantity int NOT NULL CHECK (Quantity > 0),
UnitPrice decimal(10,2) NOT NULL,
PRIMARY KEY (ProductId)
);
2. A procedure that accepts it
CREATE PROCEDURE dbo.AddOrderLines
@OrderId int,
@Lines dbo.OrderLineList READONLY
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.OrderLines (OrderId, ProductId, Quantity, UnitPrice)
SELECT @OrderId, ProductId, Quantity, UnitPrice
FROM @Lines;
END
Table-valued parameters are always READONLY: the procedure can read them, join them, but not modify them.
3. Pass a DataTable from C#
var lines = new DataTable();
lines.Columns.Add("ProductId", typeof(int));
lines.Columns.Add("Quantity", typeof(int));
lines.Columns.Add("UnitPrice", typeof(decimal));
foreach (var item in cart.Items)
{
lines.Rows.Add(item.ProductId, item.Quantity, item.UnitPrice);
}
using (var con = new SqlConnection(cs))
using (var cmd = new SqlCommand("dbo.AddOrderLines", con) { CommandType = CommandType.StoredProcedure })
{
cmd.Parameters.Add("@OrderId", SqlDbType.Int).Value = orderId;
SqlParameter p = cmd.Parameters.Add("@Lines", SqlDbType.Structured);
p.TypeName = "dbo.OrderLineList";
p.Value = lines;
con.Open();
cmd.ExecuteNonQuery();
}
- Columns are matched by position, not by name: keep the DataTable's column order identical to the type's.
TypeNameis required when calling a parameterised SQL statement and recommended for procedures.- An empty TVP is allowed: pass a DataTable with the columns and no rows (not
null).
Without a DataTable
For large volumes, streaming SqlDataRecord objects avoids building the whole table in memory:
using Microsoft.SqlServer.Server; // System.Data.dll on .NET Framework
static IEnumerable<SqlDataRecord> ToRecords(IEnumerable<CartItem> items)
{
var meta = new[]
{
new SqlMetaData("ProductId", SqlDbType.Int),
new SqlMetaData("Quantity", SqlDbType.Int),
new SqlMetaData("UnitPrice", SqlDbType.Decimal, 10, 2)
};
foreach (var i in items)
{
var r = new SqlDataRecord(meta);
r.SetInt32(0, i.ProductId);
r.SetInt32(1, i.Quantity);
r.SetDecimal(2, i.UnitPrice);
yield return r;
}
}
// p.Value = ToRecords(cart.Items); (the sequence must contain at least one record; pass null for an empty set)
Typical uses
- Saving a whole grid or cart in one transaction.
- Filtering by a list of ids:
WHERE Id IN (SELECT Id FROM @Ids), instead of building anIN (…)string, which risks SQL injection and pollutes the plan cache. - For tens of thousands of rows loaded into a table,
SqlBulkCopyis faster still; see Import CSV data into SQL Server.
Official documentation
- Microsoft Learn: Table-valued parameters (ADO.NET)
- Microsoft Learn: Use table-valued parameters (Database Engine), CREATE TYPE