Can you define ADO.NET and outline the core classes that make up its data access architecture?
KnownRevise
ADO.NET is the foundational data access technology for the .NET platform, enabling applications to interact with relational databases through a managed provider model. Its architecture is structured around two primary patterns: connected components that maintain an open session with the data source, and disconnected components that cache data locally for manipulation without a continuous connection.
Read full answerHide full answer
Applies to.NET 8 and C# 12 with Microsoft.Data.SqlClient
What Is ADO.NET
ADO.NET provides a bridge between .NET applications and data sources such as SQL Server, Oracle, or MySQL. Unlike older technologies that relied on persistent connections, ADO.NET emphasizes disconnected data access via XML and DataSets, though connected streaming remains vital for high-performance scenarios. In modern .NET development, SQL Server interactions specifically use the Microsoft.Data.SqlClient namespace, which replaces the legacy System.Data.SqlClient found in .NET Framework. This shift ensures ongoing support and new features aligned with cross-platform .NET Core and .NET 5+ evolution.
Connected Components
These classes require an active connection to the database and are optimized for forward-only, read-only access or command execution. - SqlConnection: Establishes a session with a specific SQL Server instance using a connection string. - SqlCommand: Executes SQL statements or stored procedures against the connection. - SqlDataReader: Provides a high-performance, forward-only stream of rows from the data source. - SqlTransaction: Manages database transactions to ensure atomicity across multiple operations.
Disconnected Components
These classes allow data manipulation in memory without holding an open database connection, ideal for web applications or batch processing. - SqlDataAdapter: Acts as a bridge between the DataSet and the database, using SelectCommand, InsertCommand, UpdateCommand, and DeleteCommand. - DataSet: An in-memory cache of data that can include multiple DataTables, relations, and constraints. - DataTable: Represents a single table of in-memory data within a DataSet.
Parameterization: Always use explicit parameter types (e.g., SqlDbType.Int) instead of AddWithValue, which can cause performance issues due to type inference and plan cache pollution.
Resource Management: Wrap SqlConnection, SqlCommand, and SqlDataReader in using statements to ensure connections are closed and disposed promptly.
Async Operations: Use OpenAsync, ExecuteReaderAsync, and ReadAsync in web applications to free up threads while waiting for I/O.
Null Handling: Check for DBNull before reading values; DataRow.Field<int>() throws on null, so use Field<int?>() for nullable columns.
Security: Never concatenate user input into SQL strings; rely on parameterized queries to prevent injection attacks.
Transactions: For distributed transactions on .NET 7+, note they are supported only on Windows after enabling TransactionManager.ImplicitDistributedTransactions.
2
What are the main differences between ADO.NET and the earlier ADO technology?
KnownRevise
ADO.NET is the managed, disconnected data-access architecture of .NET, while classic ADO (ActiveX Data Objects, the ADODB COM library) is a connected, cursor-oriented API built for COM clients such as Visual Basic 6 and classic ASP. The fundamental difference is that ADO.NET is designed around disconnected data exchanged as XML over a stateless, scalable provider model, whereas ADO keeps a live server cursor open for the lifetime of a recordset and relies on COM marshalling to move data between tiers.
Read full answerHide full answer
Applies toADO.NET in .NET 8 with `Microsoft.Data.SqlClient`; classic ADO (`ADODB`) as the COM library on Windows.
Architecture and Data Model
ADO: a single Connection object produces Recordset objects that combine query execution, cursor management, scrolling and update logic in one COM object. Data is represented as rows and fields accessed through Recordset.Fields.
ADO.NET: separates responsibilities into a connected layer (SqlConnection, SqlCommand, SqlDataReader, SqlDataAdapter, SqlTransaction) and a disconnected layer (DataSet, DataTable, DataRow, DataRelation). The DataSet is an in-memory relational store that can hold multiple related tables, constraints and views, independent of any specific data source.
Connected vs Disconnected Access
ADO is primarily connected: a Recordset typically holds a server-side cursor, locks server resources while open, and uses UpdateBatch for optimistic updates.
ADO.NET favours a disconnected pattern: a SqlDataAdapter fills a DataSet/DataTable, the connection is closed immediately, the data is edited in memory, and a later Update reconciles changes back to the database. This scales far better for web applications and service tiers because connections are held for the shortest possible time.
XML Integration
ADO: XML support was bolted on later through Recordset.Save with adPersistXML; the schema and row format are specific to ADO.
ADO.NET: the DataSet serialises natively to XML via WriteXml/ReadXml and WriteXmlSchema, with XmlWriteMode.WriteSchema, IgnoreSchema or DiffGram. This makes data exchange across platforms and through web services straightforward, without COM on either end.
Performance, Scalability and Interoperability
Aspect
Classic ADO
ADO.NET
Runtime
COM-based, Windows only
Managed .NET, cross-platform with Microsoft.Data.SqlClient
Data transfer
COM marshalling between processes and tiers
XML or direct managed types, no COM marshalling
Cursors
Server-side cursors by default, long-lived
Forward-only streaming DbDataReader plus disconnected DataSet
Scalability
Limited; server resources held while a recordset is open
High; connections released quickly and pooled
Type safety
Late-bound Variant fields, runtime errors
Strongly typed access, e.g. DataRow.Field<int>() and generic methods
Recommended status
Legacy, retained mainly for interoperability with existing COM applications
Current managed data-access stack for .NET
Practical Guidance
For new .NET 8 development, use ADO.NET types from Microsoft.Data.SqlClient for SQL Server, or the corresponding managed provider for other databases; classic ADO should only be referenced through COM interop to maintain existing applications.
Where a higher-level API fits, EF Core 8 builds on the same ADO.NET provider model (Microsoft.Data.SqlClient), so the connected classes remain relevant for raw SQL, bulk operations and performance-critical paths.
If you must interoperate with classic ADO, do it behind an interface and migrate the data access layer to ADO.NET or EF Core rather than extending the COM dependency.
3
What function does the DataSet class fulfill in the ADO.NET data access model?
KnownRevise
The DataSet serves as a disconnected, in-memory cache of relational data that allows applications to manipulate tables, relationships, and constraints without maintaining an open connection to the database. It enables batch operations and complex data shaping entirely within application memory before changes are propagated back to the source.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET
Core Purpose
Unlike connected readers such as SqlDataReader, a DataSet operates independently of the data source after loading. This disconnected architecture supports scenarios requiring offline data processing, multiple round-trip batching, or binding to UI controls that expect a relational structure. It holds data retrieved from one or multiple sources, preserving schema information including primary keys and data types.
Internal Structure
A DataSet contains a collection of DataTable objects, each representing a tabular result set. Relationships between tables are defined via DataRelation objects, enabling navigation similar to foreign keys. Constraints such as UniqueConstraint and ForeignKeyConstraint enforce integrity within the in-memory cache. Changes to rows are tracked via RowState (Added, Modified, Deleted, Unchanged), which determines how updates are sent back to the database.
Filling and Updating
The SqlDataAdapter bridges the database and the DataSet. Its Fill method executes a SELECT command and populates the tables. To persist changes, the adapter uses InsertCommand, UpdateCommand, and DeleteCommand. Calling Update on the adapter sends pending changes to the server. Note that DataAdapter.Update calls AcceptChanges on each saved row automatically; developers should not call AcceptChanges manually afterward.
Modern Context
While still supported in .NET 8, DataSet usage has declined in favor of Entity Framework Core or lightweight micro-ORMs like Dapper for typical CRUD operations. However, it remains relevant for legacy migration, complex in-memory analytics, or scenarios requiring XML serialization of relational schemas. Avoid using binary serialization (RemotingFormat = Binary) as it relies on BinaryFormatter, which is obsolete and removed in .NET 9.
Best Practices and Pitfalls
Null Handling: Use nullable generics with Field<T?>() to avoid InvalidCastException when database columns contain NULL values; Field<int>() throws on DBNull.
Connection Management: Keep connections open only during Fill or Update; the DataSet itself does not hold an open connection.
Serialization: Prefer JSON or XML (WriteSchema) over binary serialization for data transfer across processes.
Memory Usage: Large DataSet instances consume significant memory; consider streaming with SqlDataReader for read-only scenarios involving massive data volumes.
4
What distinguishes a DataSet from a DataReader in ADO.NET, and when should each be used?
KnownRevise
A DataSet is a disconnected, in-memory cache of data that can hold multiple tables and relationships, while a DataReader is a connected, forward-only, read-only stream of rows from the database. The core distinction is that a DataSet loads all results into memory so you can query, bind and even modify them offline, whereas a DataReader keeps the connection open and yields one row at a time, giving the best performance for read-only streaming.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (the classes are part of System.Data and behave the same in .NET Framework 4.8 with System.Data.SqlClient).
Key Differences
Aspect
DataSet
DataReader
Connection model
Disconnected: opens a connection, fills the set, then closes it
Connected: the connection must stay open while you read
Data flow
Fetches all matching rows into memory at fill time
Streams one row at a time as you call Read()
Direction
Random access to any row, supports filtering and sorting
Forward-only; you cannot revisit a previous row
Mutations
Read/write: rows can be edited, added and deleted
Strictly read-only
Structure
Can contain multiple DataTables plus DataRelations
A single result set per reader (multiple result sets from one command are reached via NextResult)
High-throughput lists, exports, web pages that only render data
How Each Works
DataSet: You create a DataSet (or a typed DataTable), pass it to a SqlDataAdapter together with a SelectCommand, and call adapter.Fill(dataSet). The adapter opens the connection, reads all rows, populates the schema and rows, and closes the connection again. Because the data lives in memory, you can iterate over it repeatedly, add DataRelation objects between tables, and serialize the contents to XML with WriteXml.
DataReader: You call command.ExecuteReader() on an open SqlConnection and loop with while (reader.Read()), accessing columns by ordinal or name with methods such as GetInt32(0) or reader["Name"]. Only one row is held in memory at a time, but the connection remains busy until you dispose the reader. If the command returns multiple result sets, you can advance through them with NextResult().
Choosing Between Them
Use a DataReader when you only need to read data once and render or stream it: populating a dropdown, filling a grid on a web page, writing a CSV export, or loading a large lookup. It minimizes both memory pressure and load time.
Use a DataSet when you need offline access, such as moving data across tiers, caching between postbacks, combining and relating several result sets, or binding to controls that require design-time schema. It is also the right choice when users will edit rows in the UI and you later persist changes through SqlDataAdapter.Update.
Prefer neither when an ORM fits: in EF Core 8, queries return strongly typed entities and AsNoTracking() gives read-only performance close to a reader without the manual mapping.
Advantages and Limitations
DataSet advantages: Works without an open connection, supports relationships and constraints, integrates with DataView for sorting/filtering, and tracks row state so updates are straightforward.
DataSet limitations: High memory cost for large results, slower first load, and it is easy to serve stale data because the snapshot is taken at fill time.
DataReader advantages: Excellent performance and a tiny memory footprint, plus access to provider-specific types.
DataReader limitations: Ties up the connection for the whole read, cannot be bound directly in some scenarios without wrapping it, and requires careful disposal so connections return to the pool.
Best Practices and Common Pitfalls
Always wrap a DataReader in a using block so it closes even on exception; otherwise the connection stays occupied and the pool is exhausted.
Open the connection just before ExecuteReader and close it as soon as reading finishes, so the reader holds the connection for the shortest possible time.
Do not perform slow work (such as calling a web service) inside a reader.Read() loop while the connection is open.
Avoid DataSet for very large queries; the memory cost scales with the entire result set and can cause avoidable garbage-collection pressure.
Remember that a DataSet is a snapshot: changes made by other users after Fill are invisible, so use it only when eventual consistency is acceptable.
5
What are the fundamental classes that constitute the ADO.NET data access architecture?
KnownRevise
ADO.NET organizes data access around a set of core classes in the System.Data namespace and provider-specific namespaces like Microsoft.Data.SqlClient to manage connections, commands, and data retrieval. The most essential components are SqlConnection, SqlCommand, SqlDataReader, SqlDataAdapter, and DataSet.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x
Connection and Command Classes
SqlConnection: Establishes a physical session with a SQL Server instance using a connection string. It must be explicitly opened before executing commands and closed afterward to release resources.
SqlCommand: Encapsulates a SQL statement, table name, or stored procedure to execute against the connection. It holds the Parameters collection used to pass values securely.
SqlTransaction: Groups multiple commands into a single atomic unit, ensuring all changes commit or roll back together via Commit() or Rollback().
Data Retrieval Models
SqlDataReader: Implements a connected, forward-only, read-only stream of rows. It offers high performance for scenarios where data is processed sequentially without modification.
SqlDataAdapter: Acts as a bridge between the database and disconnected data structures. It uses SelectCommand, InsertCommand, UpdateCommand, and DeleteCommand to fill a DataSet and propagate changes back.
DataSet and DataTable: Represent disconnected in-memory caches. DataSet can hold multiple DataTable objects with relationships, allowing manipulation without an open connection.
Best Practices and Pitfalls
Dispose Resources: Always wrap SqlConnection, SqlCommand, and SqlDataReader in using statements to prevent connection pool exhaustion.
Parameterization: Never use AddWithValue as it infers types incorrectly and harms plan caching; specify SqlDbType explicitly.
Null Handling: Check reader.IsDBNull before retrieving values to avoid InvalidCastException, and use DBNull.Value when inserting nulls.
Connected vs Disconnected: Use SqlDataReader for read-only streaming and DataSet/DataAdapter when batch updates or offline manipulation are required.
6
What role does the Connection object play in ADO.NET data access, and what operations does it enable?
KnownRevise
The SqlConnection object establishes and manages the communication session between a .NET application and a SQL Server database, serving as the mandatory prerequisite for executing commands and managing transactions. It encapsulates the connection string, authentication details, and network protocol settings required to open a physical or pooled channel to the data source.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.2
Core Responsibility
The connection object represents the active session with the database. Without an open connection, commands cannot be executed against the server. It handles the handshake, authentication (SQL Server authentication or Windows Integrated Security), and selects the initial catalog (database) specified in the connection string. It also exposes metadata about the server via properties like ServerVersion and Database.
Key Operations
A SqlConnection enables several operations beyond simply opening a channel: - Command creation: CreateCommand() produces a SqlCommand pre-bound to the connection, avoiding manual constructor wiring. - Transaction initiation: BeginTransaction() returns a SqlTransaction for atomic batches. - Database switching: ChangeDatabase(string) changes the default database for the session without reopening the connection. - Schema discovery: GetSchema() returns metadata tables about tables, columns, indexes, and other database objects. - State monitoring: The StateChange event notifies when the connection transitions between Closed, Open, Connecting, Executing, or Fetching. - Async support: OpenAsync() and CloseAsync() allow non-blocking connection management on .NET 8.
Lifecycle and State Management
Connections should be opened as late as possible and closed as early as possible. The State property indicates the current status. Explicitly calling Close() or disposing the object returns the connection to the pool rather than tearing down the physical network socket, thanks to ADO.NET pooling mechanisms.
Transaction Support
Local transactions are initiated via BeginTransaction(), which returns a SqlTransaction object. This transaction must be associated with commands created on the same connection. Committing or rolling back changes is managed through the transaction object, ensuring atomicity for a batch of operations within a single database session. For distributed transactions across multiple resources, .NET 8 supports System.Transactions.TransactionScope on Windows after enabling TransactionManager.ImplicitDistributedTransactions; this is not supported on Linux/macOS.
Connection Pooling
By default, Microsoft.Data.SqlClient enables connection pooling. When Close() is called, the physical connection remains alive in a pool keyed by the exact connection string. Subsequent Open() calls reuse existing pooled connections, significantly reducing overhead. Pooling behavior can be configured via connection string keywords like Pooling, MaxPoolSize, and ConnectionLifetime.
Best Practices
Always use using: Ensure SqlConnection and SqlCommand are wrapped in using statements to guarantee disposal and return to the pool.
Handle Nulls Correctly: Per ADO.NET rules, a C# null parameter value may be treated as not supplied. Use DBNull.Value explicitly when inserting database NULLs.
Avoid Long-Lived Connections: Do not hold connections open during user interaction or long-running computations; open them only for the duration of the data operation.
Secure Connection Strings: Store connection strings in secure configuration (e.g., Azure Key Vault or user secrets), never hardcode credentials in source code.
Check State Before Opening: Verify connection.State != ConnectionState.Open before calling Open() to avoid exceptions when reusing connection objects.
7
What is the recommended approach for managing database transactions when using ADO.NET?
KnownRevise
In ADO.NET, the recommended approach for managing database transactions is to use SqlTransaction for atomic work on a single connection and TransactionScope for ambient transactions that span multiple connections or resource managers.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient
Local Transactions with SqlTransaction
A local transaction starts by calling BeginTransaction() on an open SqlConnection, which returns a SqlTransaction. You must assign this object to the Transaction property of every SqlCommand that participates.
The transaction ends with Commit() to persist changes or Rollback() to undo them. Always call Rollback() explicitly in a catch block; do not rely on disposing the transaction to roll it back, because disposal does not guarantee an immediate rollback—the pending transaction is rolled back only when the connection is closed.
SqlTransaction supports partial rollback through savepoints: call Save(string savepointName) to mark a point you can roll back to without aborting the whole transaction.
Wrap both the connection and the transaction in using statements, but keep the explicit commit/rollback logic inside a try block.
Ambient Transactions with TransactionScope
When work touches multiple connections, databases, or other resource managers, wrap the logic in a TransactionScope. Commands automatically enlist in the ambient transaction.
For asynchronous code, pass TransactionScopeAsyncFlowOption.Enabled to the constructor; otherwise the ambient flow is not captured across await boundaries.
Escalation to a distributed transaction is platform-dependent: supported on .NET Framework; on .NET 7+ it works only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true, and is not available on Linux or macOS.
Isolation Levels
Pass a System.Data.IsolationLevel value to BeginTransaction to control locking and consistency. The default is ReadCommitted, which prevents dirty reads but allows non-repeatable reads.
Do not confuse System.Data.IsolationLevel with System.Transactions.IsolationLevel; the latter is a separate enum used by TransactionOptions when configuring a TransactionScope.
Common Pitfalls and Best Practices
Open the connection first: BeginTransaction() throws if the connection is not already open.
Never use AddWithValue: always add parameters with Parameters.Add("@name", SqlDbType.<Type>) and set Size, Precision, and Scale where the type requires them.
Handle NULLs correctly: assign DBNull.Value for database NULLs; a C# null value makes the parameter behave as if it were not supplied.
Keep transactions short: long-running transactions hold locks and hurt concurrency; commit as soon as the unit of work is complete.
Use savepoints for partial rollback: instead of assuming SqlTransaction cannot nest, use Save() and Rollback(string) to undo part of a transaction.
Retry on transient failures: cloud databases such as Azure SQL may deadlock; wrap the transaction block in a retry policy with exponential backoff.
Avoid distributed transactions when possible: they are heavier and platform-restricted; design aggregates to fit a single connection if you can.
8
Explain the mechanism behind connection pooling in ADO.NET and outline the methods used to configure its behavior.
KnownRevise
Connection pooling in ADO.NET is a performance optimization technique that maintains a cache of active database connections to reduce the overhead of establishing new sessions. Instead of creating a physical connection every time Open() is called, the provider retrieves an existing one from the pool matching the exact connection string, and returns it to the pool when Close() or Dispose() is invoked.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x
How Connection Pooling Works
When a SqlConnection object requests a connection, the provider checks the pool for an unused connection associated with the identical connection string. If found, it is reused; otherwise, a new physical connection is created up to the Max Pool Size. Closing the connection does not terminate the session but returns it to the pool for reuse, significantly reducing latency associated with authentication and network handshake. Pools are maintained per process, and a separate pool exists for each distinct connection string (and, when integrated security is used, for each Windows identity).
Configuration via Connection String
Pooling behavior is primarily controlled through keywords in the connection string. The key settings include: - Pooling: Enables or disables pooling (true by default). - Max Pool Size: The maximum number of connections allowed in the pool (default 100). - Min Pool Size: The minimum number of connections maintained in the pool (default 0). - Connection Lifetime: The maximum lifetime of a connection in seconds before it is destroyed (default 0, meaning unlimited). - Connection Reset: Determines whether the connection state is reset when returned to the pool (true by default).
Managing Pools Programmatically
While connection strings define static limits, you can manage pools dynamically using static methods on SqlConnection. SqlConnection.ClearPool(connection) empties the pool associated with a specific connection, while SqlConnection.ClearAllPools() empties all pools in the current process. This is useful during database maintenance or when credentials change, forcing the creation of fresh connections.
Code Example
The following example demonstrates opening a connection with pooling enabled and executing a parameterized query. Note the explicit parameter type definition and proper disposal to ensure the connection returns to the pool.
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public class DataAccess
{
public string GetUserName(int userId)
{
string connectionString = "Server=localhost;Database=AppDb;Trusted_Connection=True;Pooling=true;Max Pool Size=50;";
using (SqlConnection conn = new SqlConnection(connectionString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand("SELECT Name FROM Users WHERE Id = @id", conn))
{
cmd.Parameters.Add("@id", SqlDbType.Int).Value = userId;
return cmd.ExecuteScalar()?.ToString() ?? string.Empty;
}
}
}
}
Best Practices and Common Pitfalls
Always ensure connections are closed within a using block; leaving connections open exhausts the pool and causes timeout exceptions. Avoid constructing connection strings dynamically with varying whitespace or order, as pools are keyed on the exact string value. Set Min Pool Size cautiously, as pre-creating connections increases startup time and resource usage. Finally, prefer ClearAllPools only when necessary, as it negates the performance benefits of pooling until the cache warms up again.
9
What role do Command objects play in ADO.NET data access?
KnownRevise
A Command object in ADO.NET represents a SQL statement or a stored procedure that you want to run against a data source, and it is the object that actually executes that statement and returns the results. It holds the command text, a reference to the connection, parameters, and the command type, and it exposes methods for the four fundamental kinds of database operations: queries that return rows, queries that return a single value, statements that change data, and retrieval of schema or XML results.
Read full answerHide full answer
Applies toADO.NET on .NET 8 with C# 12 and `Microsoft.Data.SqlClient` (the types shown are `SqlCommand`, `SqlConnection`, `SqlParameter` from the `Microsoft.Data.SqlClient` namespace; the same design exists in `System.Data.SqlClient` on .NET Framework 4.8 and later, which is now legacy).
Core Responsibilities
Encapsulate the statement to run: the CommandText property holds the SQL text, the name of a stored procedure, or the path of a table, and CommandType tells the provider how to interpret it (CommandType.Text, CommandType.StoredProcedure, or CommandType.TableDirect).
Bind to a connection: a command is tied to a single DbConnection (a SqlConnection for SQL Server), either through its constructor or via the Connection property. The connection must be open before the command executes.
Carry parameters: the Parameters collection maps input values, output values and return codes to placeholders in the statement, which is what makes parameterized queries possible.
Control execution: properties such as CommandTimeout (how long to wait before giving up) and Transaction (enlisting the command in an SqlTransaction) let you fine-tune how and where the statement runs.
The Four Execution Methods
The provider-specific command type (SqlCommand) inherits these from DbCommand, and each method corresponds to a different kind of result:
Method
Returns
Typical use
ExecuteReader
A DbDataReader that streams rows
SELECT queries and multi-row result sets
ExecuteNonQuery
The number of rows affected
INSERT, UPDATE, DELETE and DDL statements
ExecuteScalar
A single value (column 1 of row 1)
Aggregates such as COUNT(*) or an inserted identity value
ExecuteXmlReader
An XmlReader over XML results
SQL Server FOR XML queries
A DataTable or DataSet is filled by a DbDataAdapter, which itself holds a command object for its SelectCommand, InsertCommand, UpdateCommand and DeleteCommand, so commands are also the engine behind disconnected scenarios.
Parameters and Command Types
Parameters matter for both correctness and security. Passing values through the Parameters collection instead of concatenating strings prevents SQL injection, lets the provider handle type conversion, and improves plan reuse in the database engine. For SQL Server, parameters are added with an explicit SqlDbType, and a parameter whose Value is C# null is treated as not supplied, so DBNull.Value must be used to send an actual database NULL.
Relationship to Other ADO.NET Objects
The command is the middle of the three connected objects: the Connection talks to the data source, the Command defines and runs the statement, and the DataReader or DataAdapter consumes what the command produces. A command can also enlist in a Transaction started on the same connection, so that several statements commit or roll back as a unit.
Best Practices and Common Pitfalls
Always parameterize: never build SQL by concatenating user input; use the Parameters collection for every value that comes from outside your code.
Dispose of commands and readers: wrap them in using so unmanaged resources are released, and keep one open reader per connection unless you enable MARS.
Pick the right execution method: ExecuteScalar for one value, ExecuteReader for streaming rows, ExecuteNonQuery for writes — using ExecuteReader to read a single scalar wastes resources.
Set CommandType.StoredProcedure explicitly when calling a stored procedure, and use Direction = ParameterDirection.Output for output and return parameters.
Prefer Microsoft.Data.SqlClient on .NET 8; System.Data.SqlClient is the legacy stack and receives only critical fixes.
Check for DBNull when reading results: reader.IsDBNull(0) or nullable Field<int?>() avoid exceptions on database NULLs.
10
What role does a DataAdapter play in ADO.NET, and how does it manage data flow between a database and in-memory structures?
KnownRevise
A DataAdapter acts as the bridge between a database connection and an in-memory DataSet or DataTable, enabling disconnected data access in ADO.NET. It retrieves rows from a data source to populate memory and coordinates sending inserts, updates, and deletes back to the database based on row changes tracked in the in-memory copy.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient
Core Role
The DbDataAdapter (commonly SqlDataAdapter for SQL Server) sits between the connected and disconnected layers of ADO.NET. Instead of keeping a connection open while your application processes records, the adapter opens the connection only long enough to copy data into a DataTable. Your code then works against that in-memory copy without holding an active database session, which improves scalability for web applications where holding connections open is expensive.
The Four Commands
A data adapter manages up to four command objects that define how it moves data in each direction:
SelectCommand: Executes a query to read data; used by the Fill method.
InsertCommand: Runs when a new DataRow is added to the table.
UpdateCommand: Runs when an existing DataRow is modified.
DeleteCommand: Runs when a DataRow is marked for deletion.
Only the SelectCommand is required if you are reading data. To persist changes, you must assign the other three commands manually or generate them with a SqlCommandBuilder.
Fill and Update Behavior
The Fill method opens the connection if it is closed, reads the results, and closes the connection automatically. If the connection was already open, Fill leaves it open. The Update method scans the DataTable for rows whose RowState is Added, Modified, or Deleted and executes the corresponding command for each one.
Change tracking: The DataTable tracks original and current values, allowing commands to use both for concurrency checks.
AcceptChanges: DataAdapter.Update calls AcceptChanges on each row it successfully saves, so you should not call AcceptChanges manually afterward.
Errors: If one row fails during Update, the process stops and throws an exception, leaving remaining changes unsent (unless ContinueUpdateOnError is set).
DataAdapter vs DbDataReader
Aspect
DataAdapter
DbDataReader
Data access model
Disconnected
Connected, forward-only
Connection usage
Open only during Fill/Update
Open for the entire read
In-memory structure
DataSet or DataTable
Streaming rows
Updates back to DB
Supported via command objects
Not supported
Memory overhead
Higher (full copy)
Lower (one row at a time)
Best Practices
Dispose resources: Wrap connections and adapters in using blocks to return connections to the pool promptly.
Explicit types: Always add parameters with Parameters.Add and a specific SqlDbType rather than AddWithValue, which can cause plan cache bloat and type conversion issues.
Minimal selects: Only select the columns you need; Fill copies everything returned by the SelectCommand into memory.
Concurrency: Use original values in WHERE clauses to avoid overwriting changes made by other users since the data was loaded.
11
Explain the purpose of a DataRelation within a DataSet.
KnownRevise
A DataRelation is an object that represents a parent-child relationship between two DataTable objects inside a DataSet, based on matching columns. It lets you navigate from a row in one table to the related rows in another table entirely in memory, without re-querying the database, and it is the mechanism the DataSet uses to enforce relational rules such as uniqueness and cascading updates.
Read full answerHide full answer
Applies to.NET 8 with C# 12 (ADO.NET, `System.Data`); the same API exists in .NET Framework 4.8 and .NET 9+.
How It Works
A DataRelation is created with a name, a reference to a DataColumn (or array of columns) in the parent table, and a matching DataColumn in the child table. When you add it to the DataSet.Relations collection, ADO.NET also creates two supporting objects automatically:
A UniqueConstraint on the parent column, ensuring every parent value is unique.
A ForeignKeyConstraint on the child column, which can cascade updates and deletes according to its UpdateRule and DeleteRule (both default to Rule.Cascade).
The relationship is logical metadata held by the DataSet; the data itself stays in the two independent tables, so you can build several relations over the same tables.
Key Features
Bidirectional navigation: from any DataRow you can call GetChildRows(relation) to reach the child rows, or GetParentRow(relation) / GetParentRows(relation) to reach the parent row(s). The DataRow overloads that take a DataRelation object are the strongly typed, safest form.
Constraint enforcement: ChildKeyConstraint and ParentKeyConstraint expose the auto-created constraints, so you can inspect or reconfigure cascading behaviour.
Nested XML output: setting Nested = true on a DataRelation makes DataSet.WriteXml emit child rows nested inside their parent rows instead of as separate flat tables.
Designer and typed DataSet support: in typed DataSet classes, relations surface as properties and as generated methods such as GetChildRows helpers, which makes navigation read more like business objects.
Works with disconnected data: because it lives in the DataSet, relation navigation works while the application is offline from the database.
Typical Use Cases
Master-detail UIs, such as a customers grid whose selected row drives an orders grid, using the relation for filtering and navigation rather than writing a second query.
Pre-loading a small, read-mostly reference set once and then joining it in memory, which avoids repeated round trips to SQL Server.
Exporting hierarchical XML from relational data via WriteXml with Nested = true.
Advantages and Limitations
Aspect
Benefit
Limitation
Navigation
Parent/child lookup in memory, no SQL needed
Only within one DataSet; no cross-DataSet relations
Consistency
Constraints keep child rows pointing at valid parents
Validation is in-memory only; the database is still the authoritative place for constraints
Performance
Avoids extra queries and server load
Whole related sets must be loaded, which can be large
XML
Easy hierarchical output with Nested = true
DiffGram-style sync round-trips are legacy and not recommended for new code
Best Practices and Common Pitfalls
Let ADO.NET create the constraints by adding the relation through DataSet.Relations.Add, instead of building the UniqueConstraint and ForeignKeyConstraint by hand, unless you need non-default cascade rules.
Match data types exactly between the two columns; a relation between an int key and a long foreign key will fail.
For read-only lookups, consider DataRelation plus in-memory navigation; for large or frequently changing data, a single SQL JOIN query with Microsoft.Data.SqlClient and explicit SqlDbType parameters is usually cheaper than loading both tables.
Prefer EF Core 8 for new application code, since it models relationships and navigation properties at a higher level. Keep DataSet and DataRelation for reporting, bulk-loading scenarios, or maintenance of existing .NET Framework code.
Do not rely on DataSet binary serialization (RemotingFormat = Binary); it depends on BinaryFormatter, which is obsolete and removed in .NET 9.
12
How can you filter and order the rows contained in a DataSet?
KnownRevise
You filter and sort data in a DataSet primarily through a DataView, which applies a RowFilter expression and a Sort specification over an underlying DataTable without copying the data. You can also call DataTable.Select() to get a filtered, sorted DataRow[] array.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET (`System.Data`); the example also uses Microsoft.Data.SqlClient for SQL Server access.
How DataView Filtering and Sorting Works
A DataTable holds the actual rows; a DataView is a customizable, bindable projection over those rows. Each DataTable exposes a DefaultView property, or you can construct a new DataView targeting the table.
RowFilter: a string expression, similar to a SQL WHERE clause, evaluated in memory against each row. Only rows matching the expression appear in the view.
Sort: a comma-separated list of column names, each optionally followed by ASC or DESC.
Live view: the DataView reflects changes made to the underlying DataTable (edits, adds, deletes) automatically, which is why it is ideal for data binding in Windows Forms and WPF.
RowFilter Expression Syntax
The filter expression supports a limited set of operators and functions:
Comparisons: =, <>, <, >, <=, >=
Logical: AND, OR, NOT, parentheses for grouping
Patterns: LIKE '%text%' using % (any run of characters) and _ (one character); * and ? are accepted as equivalents
Sets: IN (1, 2, 3)
NULLs: ColumnName IS NULL / ColumnName IS NOT NULL
Literals: strings in single quotes 'London', dates in #1/1/2024#, numbers unquoted
Relations: Parent().ColumnName and Child(TableName).ColumnName to navigate DataRelation objects
Escape a single quote inside a string literal by doubling it: 'O''Brien'.
Sorting with the Sort Property
The Sort string mirrors an SQL ORDER BY clause but operates on column names only:
"UnitPrice DESC" sorts one column descending.
"CategoryID ASC, ProductName ASC" sorts by multiple columns.
Sorting is applied after filtering, so the view shows only matching rows in the requested order.
DataTable.Select() as an Alternative
DataTable.Select() returns a DataRow[] snapshot rather than a live view:
Select() returns all rows.
Select(filterExpression) filters only.
Select(filterExpression, sort) filters and sorts.
Select(filterExpression, sort, recordStates) also filters by DataViewRowState (e.g. DataViewRowState.ModifiedCurrent, DataViewRowState.Added), useful for inspecting pending changes before Update().
Use Select() when you need a one-time array for processing; use a DataView when you need binding, currency management, or a view that stays synchronized with the table.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
var dataSet = new DataSet();
// Fill the DataSet from SQL Server
using var connection = new SqlConnection(
"Server=localhost;Database=Northwind;Trusted_Connection=True;TrustServerCertificate=True;");
using var command = new SqlCommand(
"SELECT ProductID, ProductName, UnitPrice, UnitsInStock, CategoryID FROM Products", connection);
using var adapter = new SqlDataAdapter(command);
adapter.Fill(dataSet, "Products");
DataTable products = dataSet.Tables["Products"];
// 1. Filter and sort with a DataView (live, bindable view)
DataView view = products.DefaultView;
view.RowFilter = "UnitPrice > 20 AND CategoryID = 1 AND ProductName LIKE '%cheese%'";
view.Sort = "UnitPrice DESC, ProductName ASC";
foreach (DataRowView rowView in view)
{
Console.WriteLine($"{rowView["ProductName"]}: {rowView["UnitPrice"]}");
}
// 2. Filter and sort with Select() (returns a DataRow[] snapshot)
DataRow[] rows = products.Select("UnitsInStock > 0", "ProductName ASC");
// 3. Filter by row state, e.g. rows modified since last AcceptChanges
DataRow[] modified = products.Select(null, null, DataViewRowState.ModifiedCurrent);
// 4. Handle NULLs safely when reading typed values
foreach (DataRow row in rows)
{
int? stock = row.Field<int?>("UnitsInStock"); // nullable: does not throw on DBNull
if (stock > 0)
{
// process row
}
}
Best Practices and Common Pitfalls
Filter in the query when possible: RowFilter runs in memory over the full result set. For large tables, push filtering and sorting into the SQL WHERE and ORDER BY clauses to reduce memory and network traffic.
Never build RowFilter from untrusted input: the expression is not parameterized. Validate or escape user input before concatenating it, or better, filter in SQL with parameters.
Quote literals correctly: strings need single quotes, dates need #, and numbers must not be quoted. '10' compared to a numeric column can cause type-mismatch errors.
DBNull and typed access: DataRow.Field<T>() throws if the column contains DBNull. Use the nullable form Field<int?>() for columns that can be null.
DataView does not copy data: edits through a DataRowView modify the underlying DataTable. Clone the table first if you need an independent working copy.
String comparisons respect CaseSensitive and Locale: filtering and sorting of string columns follow the table’s CaseSensitive property (default false, so comparisons are case-insensitive) and the table’s Locale; set DataTable.CaseSensitive = true if you need case-sensitive matching. DataView.ApplyDefaultSort only takes effect when Sort is empty and the table has a primary key.
13
Explain what an ADO.NET DataProvider is and describe the criteria for selecting the right one for an application.
KnownRevise
A DataProvider in ADO.NET is a set of classes that implements the System.Data.Common base classes (DbConnection, DbCommand, DbDataReader, DbDataAdapter, DbParameter, DbTransaction) so that your code can talk to a specific data source while exposing a consistent, provider-independent programming model. Choosing one means matching the provider to your database engine, target platforms, feature requirements, licensing, and whether you need raw ADO.NET access or an ORM such as EF Core on top of it.
Read full answerHide full answer
Applies to.NET 8 with C# 12 (ADO.NET via `System.Data.Common` and third-party NuGet packages)
What a DataProvider Contains
Connection class: opens and manages a session to the data source (e.g. SqlConnection for SQL Server).
Command class: executes SQL text, stored procedures, or parameterized statements and returns readers, scalar values, or affected row counts.
DataReader class: streams result sets forward-only, read-only, with IEnumerable support so it works with LINQ while streaming.
DataAdapter and CommandBuilder: fill DataSet/DataTable and push disconnected changes back to the database in batched, disconnected scenarios.
Parameter class: binds values with an explicit database type, which is the mechanism behind parameterized queries and SQL injection protection.
Transaction class: groups commands in a transaction with a chosen isolation level.
Provider factory: a DbProviderFactory implementation lets you write code that is not bound to a specific vendor’s types, useful for multi-database products.
Common Providers
Provider
NuGet package
Target database
Notes
Microsoft SQL Server
Microsoft.Data.SqlClient
SQL Server, Azure SQL Database, Synapse
Current recommended client; supports TLS 1.2+, Microsoft.Data.SqlClient authentication options such as Active Directory, and Always Encrypted
SQL Server (legacy)
System.Data.SqlClient
SQL Server
Deprecated; use Microsoft.Data.SqlClient for new code
PostgreSQL
Npgsql
PostgreSQL, Aurora PostgreSQL
Open source, strong async and EF Core support
MySQL / MariaDB
MySqlConnector
MySQL, MariaDB
Recommended over the older MySql.Data for modern .NET
Oracle
Oracle.ManagedDataAccess.Core
Oracle Database
Fully managed; note that it binds parameters by position by default, so set OracleCommand.BindByName = true for named parameters
SQLite
Microsoft.Data.Sqlite
SQLite
Lightweight, file-based, often used in mobile/desktop and tests
ODBC / OLE DB
System.Data.Odbc, System.Data.OleDb
Any ODBC/OLE DB source
Interop-based bridge, mainly for legacy sources on Windows
How to Choose
Match the engine first: the provider must support your exact database and version, including cloud variants such as Azure SQL Database or Amazon RDS/Aurora.
Prefer managed assemblies: fully managed packages such as Microsoft.Data.SqlClient, Npgsql, and Oracle.ManagedDataAccess.Core avoid native dependencies and simplify cross-platform and container deployment.
Check platform support: some providers are Windows-only or have partial Linux/macOS support; verify the package’s support matrix for your runtime and OS.
Feature requirements: confirm support for async (OpenAsync, ExecuteReaderAsync), connection pooling, multiple active result sets, bulk copy, distributed transactions, TLS configuration, and authentication modes such as managed identity or OAuth.
Distributed transactions are constrained: they are supported on .NET Framework, and on .NET 7+ only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; they are not supported on Linux/macOS.
ORM compatibility: if you use EF Core 8, pick a provider with a mature EF Core package (for example Microsoft.EntityFrameworkCore.SqlServer, Npgsql.EntityFrameworkCore.PostgreSQL) — the EF Core provider wraps the underlying ADO.NET provider.
Performance and scale: compare streaming reader behavior, batching, prepared statements, and pooling defaults; for high-throughput inserts, check whether bulk copy or a native fast-load path exists.
Licensing, support, and maintenance: prefer packages with active maintainers, security patch history, and a license compatible with your product; community forks may lack long-term support.
Consistency across environments: choose one provider family for the whole solution so connection strings, parameter handling, and type mapping behave identically in development, tests, and production.
Best Practices and Common Pitfalls
Use parameterized commands with explicit types for every value, and never concatenate user input into SQL text.
Send DBNull.Value when a parameter must be a database NULL; a C# null in Value is treated as “not supplied”.
Dispose connections, commands, readers, and transactions with using blocks so pooled connections return promptly.
Do not mix providers casually: System.Data.SqlClient and Microsoft.Data.SqlClient have similar names but different namespaces, assembly versions, and feature sets.
Write provider-agnostic code only when justified: DbProviderFactory adds flexibility but hides vendor-specific features and can make debugging harder, so prefer a direct, well-understood provider when you target a single database.
14
What is a parameterized query in ADO.NET, and how does it work?
KnownRevise
A parameterized query is a SQL statement in which literal values are replaced by named or positional placeholders, with the actual values supplied separately as DbParameter objects attached to the command. ADO.NET sends the query text and the values to SQL Server in distinct parts of the request, so the values are never parsed as SQL code and the plan cache can be reused safely.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (also applies to earlier versions; in .NET Framework 4.8 with System.Data.SqlClient the API is identical)
How It Works
You write SQL with placeholders, for example SELECT * FROM Customers WHERE Country = @country.
You create a SqlCommand (or another provider’s DbCommand) with that text.
For each placeholder you add a parameter to command.Parameters, giving an explicit SqlDbType, and set its Value.
The provider transmits the statement as an RPC call (sp_executesql for SQL Server) with the parameters bound separately, rather than concatenating values into the string.
Because the database engine never sees the values inside the statement text, it cannot confuse data with code, and the same prepared plan can be reused for different values.
Key Features
SQL injection protection: the classic defence for dynamic SQL. Even if a user types ' OR 1=1 --, it is passed as a literal string value, not executed as SQL.
Plan reuse: SQL Server caches one execution plan for the parameterized statement instead of one plan per distinct literal value, which improves performance and reduces cache pressure.
Type safety: an explicit SqlDbType (with Size for strings and binary, Precision/Scale for decimals) makes the value’s database type unambiguous and avoids implicit conversions that can hurt performance.
Readable, maintainable code: no string concatenation, quoting rules or date-format pitfalls.
Provider-agnostic pattern: every ADO.NET provider implements it through DbCommand.Parameters; SQL Server uses @name, Oracle uses :name (and ODP.NET binds by position unless you set OracleCommand.BindByName = true), OLE DB and ODBC use ? positional markers.
Advantages and Limitations
Aspect
Parameterized query
Inline concatenated SQL
SQL injection
Values are data, not code
Vulnerable unless carefully escaped
Execution plans
Cached and reused per statement shape
Often one plan per distinct string
Type handling
Explicit and checked at runtime
Depends on string conversion
Readability
Clear separation of code and values
Messy quoting and formatting
Limitations
Cannot parameterize identifiers (table/column names) or whole SQL clauses; those need QUOTENAME/whitelisting
Seemingly more flexible, but unsafe
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = "Server=.;Database=Northwind;Integrated Security=true;Encrypt=true;TrustServerCertificate=false;";
// The values come from an untrusted source (e.g. a web request).
string country = "USA";
DateTime registeredFrom = new DateTime(2020, 1, 1);
const string sql = """
SELECT CustomerId, CompanyName, Country, RegisteredAt
FROM Customers
WHERE Country = @country
AND RegisteredAt >= @registeredFrom
ORDER BY CompanyName;
""";
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(sql, connection);
// Explicit type, size and precision for every parameter.
command.Parameters.Add("@country", SqlDbType.NVarChar, 50).Value = country;
command.Parameters.Add("@registeredFrom", SqlDbType.DateTime2).Value = registeredFrom;
connection.Open();
using var reader = command.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($"{reader["CustomerId"]}: {reader["CompanyName"]}");
}
Common Pitfalls
Never use AddWithValue: it infers the type from the value, which can produce wrong lengths or implicit conversions. Use command.Parameters.Add("@name", SqlDbType.NVarChar, 50) and then set .Value.
null vs DBNull.Value: a parameter whose Value is C# null is treated as not supplied. Send DBNull.Value when you mean database NULL.
Parameter direction: the default is Input; for output values from stored procedures use Direction = ParameterDirection.Output and read the value after execution.
Identifier names cannot be parameters: SELECT * FROM @table is invalid. Validate against a whitelist or use QUOTENAME in T-SQL.
Best Practices
Prefer parameterized queries or stored procedures over any string-built SQL, including inside SqlCommand.CommandText.
Keep parameter names and SQL placeholders in sync; a missing or extra parameter raises an error at execution time.
Reuse the same SqlCommand object with different parameter values when executing repeatedly, rather than rebuilding the string each time.
15
How do you implement optimistic concurrency control using ADO.NET to prevent lost updates during simultaneous data modifications?
KnownRevise
Optimistic concurrency in ADO.NET is implemented by verifying that data has not changed since it was loaded, typically using a rowversion column in the WHERE clause of an UPDATE statement. If the update affects zero rows, a conflict is detected and handled gracefully rather than locking the record beforehand.
Optimistic concurrency assumes conflicts are rare. Instead of locking rows (pessimistic), the application reads data along with a version token. When saving changes, the token is included in the WHERE clause. If another transaction modified the row, the token no longer matches, and the update fails safely.
Database Schema Requirement
The table must track changes automatically. A rowversion (formerly timestamp) column is ideal because SQL Server increments it automatically on every modification. - Column Type: rowversion (8 bytes binary). - Behavior: Non-nullable, unique within the database, increments per write. - Alternative: Check all original column values in the WHERE clause, but this is verbose and less reliable for large rows.
Update Logic and Conflict Detection
The UPDATE statement must filter by the primary key AND the original version. The return value of ExecuteNonQuery() indicates success. - Rows Affected = 1: Update succeeded; refresh the version token. - Rows Affected = 0: Conflict detected; the row was changed by another process. - Exception Handling: Network errors or deadlocks throw exceptions; distinguish these from logical conflicts.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public bool UpdateProductName(int id, string name, byte[] currentVersion)
{
// Use Windows authentication; avoid TrustServerCertificate in production.
var connectionString = "Server=.;Database=Inventory;Trusted_Connection=True;";
var sql = "UPDATE Products SET Name = @name WHERE Id = @id AND RowVersion = @version";
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(sql, connection);
// Explicit types and sizes prevent type inference and plan cache pollution.
command.Parameters.Add("@name", SqlDbType.NVarChar, 100).Value = name;
command.Parameters.Add("@id", SqlDbType.Int).Value = id;
// rowversion is binary(8); pass the original 8-byte token.
command.Parameters.Add("@version", SqlDbType.RowVersion, 8).Value = currentVersion;
try
{
connection.Open();
int rowsAffected = command.ExecuteNonQuery();
return rowsAffected == 1;
}
catch (SqlException ex) when (ex.Number == -2 || ex.Number == 1205)
{
// Timeout (-2) or deadlock (1205): treat as a transient conflict and retry.
throw;
}
}
Best Practices and Pitfalls
Refresh Version: On success, retrieve the new rowversion (via OUTPUT clause or SELECT) to keep the client state current.
Null Handling: Never pass C# null for version parameters; use DBNull.Value if the column allows nulls, though rowversion typically does not.
Transaction Scope: Wrap the read and update in a transaction if intermediate state consistency is required, but keep transactions short to avoid blocking.
User Experience: On conflict, reload the data and prompt the user to merge changes rather than silently overwriting.
Avoid AddWithValue: Always specify SqlDbType explicitly to prevent type inference errors and plan cache pollution.
16
How do you implement bulk data insertion into SQL Server using the SqlBulkCopy class in ADO.NET?
KnownRevise
SqlBulkCopy is the high-performance ADO.NET class designed to load large volumes of data into a SQL Server table efficiently by leveraging the bulk insert protocol. It minimizes network round-trips and logging overhead compared to individual INSERT statements, making it ideal for ETL processes and data migrations.
The class operates by streaming data from a source implementing IDataReader, a DataTable, or an array of DataRow objects directly to the server. You instantiate SqlBulkCopy with an open SqlConnection, configure the destination table via DestinationTableName, and invoke WriteToServer. By default it uses row-level locks; specifying SqlBulkCopyOptions.TableLock acquires a bulk update lock and can enable minimal logging when the database recovery model allows, significantly improving throughput for datasets exceeding thousands of rows.
Configuration Options and Behavior
Key properties control performance and integrity. BatchSize determines how many rows are sent in a single round-trip; setting this too high may cause memory pressure, while too low reduces efficiency. BulkCopyTimeout sets the time limit before the operation aborts. Crucially, SqlBulkCopyOptions alters default behavior: by default, constraints are not checked and triggers are not fired. To enforce these, pass SqlBulkCopyOptions.CheckConstraints or SqlBulkCopyOptions.FireTriggers during construction. Column mapping is handled via ColumnMappings if source column names or indices do not match the destination schema.
Transaction Management
Transaction behavior is a common source of confusion. If you set SqlBulkCopyOptions.UseInternalTransaction, each batch commits independently. This means a failure in a later batch does not roll back earlier batches, violating atomicity. For true all-or-nothing semantics, create an explicit SqlTransaction on the connection and pass it to the SqlBulkCopy constructor. This ensures that if any part of the bulk operation fails, the entire transaction rolls back. Note that distributed transactions require specific OS and framework support (Windows, .NET 7+ with implicit distributed transactions enabled), but local transactions work across platforms.
Code Example
The following example demonstrates a safe bulk insert using an external transaction and explicit column mapping.
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public void BulkInsertData(string connectionString, DataTable dataTable)
{
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
try
{
using (var bulkCopy = new SqlBulkCopy(
connection,
SqlBulkCopyOptions.Default,
transaction))
{
bulkCopy.DestinationTableName = "dbo.TargetTable";
bulkCopy.BatchSize = 1000;
bulkCopy.ColumnMappings.Add("SourceCol", "DestCol");
bulkCopy.WriteToServer(dataTable);
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
Best Practices and Common Pitfalls
Always validate data types before calling WriteToServer; mismatches cause runtime exceptions that may leave partial data if transactions are misconfigured. Avoid using SqlBulkCopy for small datasets (under 1000 rows) where standard parameterized commands suffice, as the setup overhead outweighs benefits. Remember that DBNull.Value is required for database NULLs; C# null in a DataRow may be interpreted differently depending on the source type. Finally, never rely on UseInternalTransaction for critical data loads requiring atomicity, and ensure the destination table identity settings (like IDENTITY_INSERT) are configured if you are inserting explicit identity values.
17
How do typed DataSets differ from untyped DataSets within the ADO.NET framework?
KnownRevise
A Typed DataSet is a DataSet subclass generated from a schema that exposes tables and columns as strongly-typed properties and methods, whereas an Untyped DataSet is the base DataSet class accessed through string-named tables, columns, and object values at runtime. The typed version provides compile-time checking and IntelliSense; the untyped version uses late-bound string lookups that can fail only at runtime.
Read full answerHide full answer
Applies to.NET 8 with System.Data (included in the base class library; no separate NuGet package required)
Schema Definition and Generation
Untyped DataSets are created dynamically at runtime by adding DataTable and DataColumn objects, or by filling a DataSet with a DataAdapter when no schema is known in advance.
Typed DataSets are generated at design time from an XML Schema (XSD) file or a database schema using Visual Studio or xsd.exe. The generator produces a class derived from DataSet, plus typed DataTable and typed DataRow classes.
Both share the same underlying disconnected data graph, change tracking, XML schema inference, and DataAdapter update behavior.
Data Access and Type Safety
Untyped access uses string indexers: row["Name"] returns object, requiring casts such as (string)row["Name"] or row.Field<string>("Name"). A missing column throws IndexOutOfRangeException; a database NULL returns DBNull.Value.
Typed access uses native properties: row.Name returns string directly. If the underlying value is DBNull, the generated accessor throws StrongTypingException; use the generated IsNameNull() method to test first.
Typed tables expose typed collection methods, for example users.AddUsersRow("Bob") and users.Users[0].Name, while untyped tables require Rows.Add and string-based indexing.
Compile-Time Safety and Error Behavior
Renaming a column in the XSD regenerates the typed class, so stale references fail at compile time. Untyped column-name typos compile successfully and fail only at runtime.
Typed DataSets reduce boilerplate and make data-access code easier to refactor, but they add a generation step and can produce large generated files.
Untyped DataSets are more flexible for dynamic or ad-hoc schemas whose shape is not known until runtime.
Comparison Table
Aspect
Untyped DataSet
Typed DataSet
Base class
System.Data.DataSet
Generated subclass of DataSet
Column access
row["Name"] returning object
row.Name returning the mapped .NET type
Schema knowledge
Not required at compile time
Required at design/generation time
Errors for bad names
IndexOutOfRangeException at runtime
Compile-time error
NULL handling
Returns DBNull.Value
Throws StrongTypingException; use IsXxxNull()
Tooling
Plain code or runtime schema
XSD designer / xsd.exe
Best fit
Dynamic schemas, quick prototypes
Stable schemas, long-lived maintenance
Best Practices and Modern Context
DataSets are supported in .NET 8 but are legacy compared with EF Core, Dapper, or POCO/JSON models. Use them mainly for maintaining existing disconnected-data scenarios or when XML schema/diffgram serialization is required.
Avoid DataSet.RemotingFormat = Binary; it depends on BinaryFormatter, which is obsolete and removed in .NET 9. Prefer XML serialization or modern formats.
Reference System.Data for DataSet, DataTable, SqlDbType, and DataAdapter. System.Data.Common is the base-provider namespace and is not the primary namespace for typed DataSets.
For new development, prefer EF Core 8 with Microsoft.Data.SqlClient for SQL Server; reserve typed DataSets for interoperability with legacy components or reporting APIs that require DataTable.
18
Describe how you persist changes made in a DataSet back to the underlying database.
KnownRevise
You push pending edits from a DataSet to the database by calling DataAdapter.Update(), which inspects the RowState of each row in the DataTable and executes the adapter’s InsertCommand, UpdateCommand or DeleteCommand for added, modified or deleted rows. After each row is saved successfully, the adapter calls AcceptChanges on that row, resetting its state to Unchanged.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and `Microsoft.Data.SqlClient` (the supported successor to the legacy `System.Data.SqlClient`).
How It Works
You fill the DataSet with a SqlDataAdapter, usually inside a using block.
The user or business logic edits rows in memory: adding, changing or removing DataRow objects.
Each row’s RowState becomes Added, Modified or Deleted; the row also keeps its original and current values.
adapter.Update(table) walks the rows and runs the matching command for each state.
Each saved row is committed in-memory via AcceptChanges. Do not call table.AcceptChanges() yourself before Update — that marks every row Unchanged so nothing is saved, and after Update it is redundant because the adapter has already done it.
Key Components
SelectCommand: reads the data; its schema can be used to generate the other commands.
InsertCommand / UpdateCommand / DeleteCommand: the explicit SqlCommand objects that write changes. Their parameters must map to the source column and, for updates and deletes, to the original values used in the WHERE clause.
SqlCommandBuilder: generates the three write commands from the SelectCommand at runtime. Convenient for simple single-table scenarios, but it does not handle joins. Its default ConflictOption.CompareKeySearchableValues compares the primary key plus other searchable columns; choose CompareAllValues to compare every column or CompareRowVersion with a rowversion column.
UpdateBatchSize: batches statements to reduce round trips when saving many rows.
ContinueUpdateOnError: when true, failed rows are skipped and their errors are recorded in RowError instead of throwing; otherwise the first error stops the update.
Explicit Commands vs. CommandBuilder
Aspect
Hand-written commands
SqlCommandBuilder
Control
Full control over SQL, concurrency and ordering
Generated automatically
Complex schemas
Works with joins, stored procedures, custom logic
Single table only
Performance
Can be tuned and cached
Extra schema query at generation time
Maintainability
More code, clearer intent
Less code, hidden behaviour
Optimistic Concurrency
Because a DataSet is disconnected, the safest pattern is optimistic concurrency: the WHERE clause of UpdateCommand and DeleteCommand compares against the original column values fetched from the server. If another user changed the row first, the command affects zero rows and DBConcurrencyException is thrown for that row. Alternatives are “last writer wins” (match only the primary key) or adding a row version column such as rowversion.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true;";
using var connection = new SqlConnection(connectionString);
connection.Open();
// 1. Fill the DataSet
var adapter = new SqlDataAdapter
{
SelectCommand = new SqlCommand("SELECT Id, Name, Price FROM Products", connection)
};
var table = new DataTable();
adapter.Fill(table);
// 2. Edit in memory
table.Rows[0]["Price"] = 19.99m; // Modified
table.Rows.Add(101, "Widget", 5.50m); // Added
table.Rows[2].Delete(); // Deleted
// 3. Configure write commands with explicit parameter types
adapter.InsertCommand = new SqlCommand(
"INSERT INTO Products (Id, Name, Price) VALUES (@id, @name, @price)", connection);
adapter.InsertCommand.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
var insertName = adapter.InsertCommand.Parameters.Add("@name", SqlDbType.NVarChar);
insertName.Size = 100;
insertName.SourceColumn = "Name";
adapter.InsertCommand.Parameters.Add("@price", SqlDbType.Money).SourceColumn = "Price";
adapter.UpdateCommand = new SqlCommand(
"UPDATE Products SET Name = @name, Price = @price WHERE Id = @id", connection);
var updateName = adapter.UpdateCommand.Parameters.Add("@name", SqlDbType.NVarChar);
updateName.Size = 100;
updateName.SourceColumn = "Name";
adapter.UpdateCommand.Parameters.Add("@price", SqlDbType.Money).SourceColumn = "Price";
adapter.UpdateCommand.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
adapter.DeleteCommand = new SqlCommand("DELETE FROM Products WHERE Id = @id", connection);
adapter.DeleteCommand.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
// 4. Persist changes
adapter.Update(table); // AcceptChanges is called per saved row
Best Practices and Common Pitfalls
Never use AddWithValue; always add parameters with an explicit SqlDbType plus Size, Precision or Scale when the type needs them.
A parameter whose Value is C# null is treated as not supplied; assign DBNull.Value when you mean a database NULL.
Do not call table.AcceptChanges() before Update — that marks every row Unchanged and nothing is saved.
Wrap Update in a SqlTransaction when all changes must succeed or fail together; without it, each statement runs in its own implicit transaction.
For bulk inserts, SqlBulkCopy is far faster than row-by-row DataAdapter updates; note that by default it does not check constraints or fire triggers.
In .NET Framework-only code, SqlDataAdapter supports distributed transactions through TransactionScope; on .NET 7+ that requires Windows and TransactionManager.ImplicitDistributedTransactions = true, and it is not supported on Linux or macOS.
19
What is the correct approach for managing database NULL values when reading and writing data using ADO.NET in modern .NET applications?
KnownRevise
In ADO.NET, database NULLs must be explicitly represented using DBNull.Value, which is a singleton object distinct from C# null. Treating them interchangeably causes invalid cast exceptions during reads or unintentional default values during writes, so conversion logic is required at the boundary between managed code and the database.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x
Understanding DBNull vs C# null
ADO.NET uses System.DBNull to represent missing data in a database context. While C# null indicates a reference type has no object, DBNull.Value indicates a column value does not exist in the row. When passing parameters to SQL Server, assigning C# null to a parameter’s Value property often results in the parameter being treated as unset rather than NULL, unless handled explicitly. This distinction is critical because the database engine expects a specific token for missing data.
Reading NULL Values
When retrieving data, always check for nulls before casting. With SqlDataReader, use IsDBNull or GetFieldValue<T?> where supported. When working with DataTable rows, the Field<T> extension method is convenient. Note that DataRow.Field<int>() throws on DBNull; use Field<int?>() for nullable columns to return a nullable C# type instead of throwing an exception. This ensures robust data retrieval without try-catch blocks around every column access.
Writing NULL Values
When inserting or updating, C# nulls must be converted to DBNull.Value before assignment. A common pattern is using the null-coalescing operator cast to object. Parameters must declare their SqlDbType explicitly to avoid type inference errors that AddWithValue might introduce, especially for nullable types where the provider cannot infer the SQL type from a null reference. This guarantees the database receives the correct metadata for the parameter.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public class NullHandler
{
public void InsertData(string connectionString, string name, int? age)
{
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand("INSERT INTO T (Name, Age) VALUES (@Name, @Age)", connection);
// Explicit types required; convert C# null to DBNull.Value
command.Parameters.Add("@Name", SqlDbType.NVarChar, 100).Value = name ?? (object)DBNull.Value;
command.Parameters.Add("@Age", SqlDbType.Int).Value = age.HasValue ? (object)age.Value : DBNull.Value;
connection.Open();
command.ExecuteNonQuery();
}
}
Common Pitfalls
Avoid AddWithValue because it infers types from the value, which fails to distinguish between an empty string and NULL or misinfers nullable integers. Do not call AcceptChanges manually after DataAdapter.Update, as the method handles state tracking automatically. Remember that distributed transactions require explicit configuration on .NET 7+ Windows and are unsupported on Linux, though this rarely impacts simple NULL handling. Always validate input before constructing parameters to prevent injection.
20
How does Entity Framework relate to the ADO.NET architecture, and what abstraction layer does it provide over traditional data access mechanisms?
KnownRevise
Entity Framework is not a component within ADO.NET itself but is an Object-Relational Mapper (ORM) built on top of ADO.NET providers. It automates the low-level data access tasks that ADO.NET handles manually, such as connection management, command execution, and object mapping, enabling developers to interact with databases using domain objects instead of raw tables and SQL.
Read full answerHide full answer
Applies to.NET 8, EF Core 8, ADO.NET (Microsoft.Data.SqlClient)
Architectural Relationship
ADO.NET serves as the foundational data access layer in .NET, providing core classes like DbConnection, DbCommand, and DbDataReader within System.Data and provider namespaces such as Microsoft.Data.SqlClient. EF Core exists as a separate library (Microsoft.EntityFrameworkCore) that relies on these ADO.NET classes to communicate with the database. When EF Core processes a query or saves changes, it internally opens an ADO.NET connection, constructs commands, and reads results, shielding the developer from this boilerplate.
Levels of Abstraction
ADO.NET: Requires explicit SQL writing, manual parameter typing, and result set mapping. It offers granular control over performance and query logic.
EF Core: Translates LINQ queries into SQL and manages change tracking, migrations, and relationships automatically. It reduces boilerplate but adds a translation layer.
Interoperability: EF Core exposes the underlying ADO.NET connection via Database.GetDbConnection(), permitting raw ADO.NET commands to run within an EF-managed context when necessary.
Under the Hood Mechanics
When context.SaveChanges() is invoked, EF Core generates SQL based on tracked entity changes and executes them using the configured provider. Parameters are added internally using parameterized commands with explicit types, mirroring secure ADO.NET practices. This abstraction reduces development time but requires developers to understand the generated SQL to avoid performance issues, such as inefficient joins or N+1 query patterns.
Comparison Table
Feature
ADO.NET
EF Core
Primary Use
Complex SQL, bulk operations
Rapid development, domain modeling
Learning Curve
Steeper (SQL + API)
Moderate (LINQ + Config)
Control
Full control over SQL
Abstracted, configurable
Package
Microsoft.Data.SqlClient
Microsoft.EntityFrameworkCore
Namespace
System.Data, Microsoft.Data.SqlClient
Microsoft.EntityFrameworkCore
Best Practices
Select the Right Tool: Use ADO.NET for high-throughput bulk operations or complex stored procedures where SQL tuning is critical. Prefer EF Core for standard CRUD operations and domain-driven design.
Manage State Carefully: When mixing raw ADO.NET with EF Core on the same connection, ensure both share the same transaction context to maintain data integrity and avoid change tracking conflicts.
Inspect Generated SQL: Enable EF Core logging to review translated SQL queries, ensuring abstraction does not hide performance bottlenecks.
Avoid Obsolete Patterns: Do not use System.Data.SqlClient (deprecated in favor of Microsoft.Data.SqlClient) or AddWithValue for parameters, as these compromise type safety and performance.
21
What is the process for associating a database transaction with a DataAdapter to ensure atomic batch updates?
KnownRevise
To use transactions with a DataAdapter, you must assign the same SqlTransaction instance to the InsertCommand, UpdateCommand, and DeleteCommand properties before calling Update, ensuring all batched changes commit or roll back together.
The DataAdapter does not manage transactions itself. Instead, you create a transaction via SqlConnection.BeginTransaction() and explicitly assign it to the Transaction property of every SqlCommand object linked to the adapter. If any command lacks this assignment, it executes outside the transaction scope, breaking atomicity. The connection must remain open for the duration of the transaction.
Implementation Steps
Open a SqlConnection and call BeginTransaction() to obtain a SqlTransaction.
Create SqlCommand objects for insert, update, and delete operations, passing the connection and transaction to their constructors.
Map each parameter to a DataTable column using SourceColumn (and set SqlDbType/size explicitly) so the adapter supplies per-row values.
Attach the commands to the corresponding properties of the SqlDataAdapter.
Execute adapter.Update(dataTable) within a try block.
Call transaction.Commit() on success or transaction.Rollback() in the catch block.
Update Behavior and State Management
When Update is called, the adapter examines row states in the DataTable and executes the appropriate command for each changed row. If an exception occurs during this process, the transaction remains open, allowing you to roll back all changes made so far. Crucially, DataAdapter.Update automatically calls AcceptChanges on rows that are successfully saved; you should not invoke AcceptChanges manually after a successful update, as this is redundant and can interfere with change tracking.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public void UpdateWithTransaction(string connectionString, DataTable table)
{
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
using (var transaction = connection.BeginTransaction())
{
using (var adapter = new SqlDataAdapter())
{
try
{
// Insert Command
using var insertCmd = new SqlCommand(
"INSERT INTO Users (Id, Name) VALUES (@id, @name)", connection, transaction);
insertCmd.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
insertCmd.Parameters.Add("@name", SqlDbType.NVarChar, 100).SourceColumn = "Name";
adapter.InsertCommand = insertCmd;
// Update Command
using var updateCmd = new SqlCommand(
"UPDATE Users SET Name = @name WHERE Id = @id", connection, transaction);
updateCmd.Parameters.Add("@name", SqlDbType.NVarChar, 100).SourceColumn = "Name";
updateCmd.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
adapter.UpdateCommand = updateCmd;
// Delete Command
using var deleteCmd = new SqlCommand(
"DELETE FROM Users WHERE Id = @id", connection, transaction);
deleteCmd.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
adapter.DeleteCommand = deleteCmd;
adapter.Update(table);
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
}
}
Best Practices and Pitfalls
Keep Transactions Short: Minimize the time between BeginTransaction and Commit to reduce locking and blocking on the database server.
Explicit Parameter Types: Always specify SqlDbType and size (e.g., SqlDbType.NVarChar, 100) when adding parameters to avoid type inference issues and plan cache pollution. Never use AddWithValue.
Map Parameters to Columns: Set SourceColumn on each parameter so the DataAdapter pulls the value from the current DataRow; hard-coding .Value applies the same literal to every row.
Exception Handling: Always roll back the transaction in a catch block before rethrowing or handling the error; leaving a transaction open can exhaust connection pool resources.
Connection Scope: Ensure the SqlConnection used for the transaction is the same one passed to the commands and remains open until Commit or Rollback is called.
Dispose Resources: Wrap SqlConnection, SqlTransaction, SqlDataAdapter, and SqlCommand in using (or using var) statements to release handles promptly.
Null Handling: If a column allows NULLs, assign DBNull.Value instead of C# null to parameter values to send a database NULL correctly.
22
How do you establish and later release a database connection using ADO.NET?
KnownRevise
In ADO.NET you establish a database connection by creating a provider-specific connection object with a connection string, calling its Open() method, executing commands against it, and then releasing it — ideally by declaring it in a using block so that Dispose() closes it even if an exception is thrown. For SQL Server the modern type is SqlConnection from the Microsoft.Data.SqlClient package.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and the `Microsoft.Data.SqlClient` NuGet package. The same pattern applies to other providers (`NpgsqlConnection`, `OracleConnection` from `Oracle.ManagedDataAccess.Core`, `SQLiteConnection`) with provider-specific names.
The Core Steps
Create a connection object and pass it the connection string, which identifies the server, database, credentials, and options.
Call Open() before running any command or starting a transaction.
Run your commands, readers, or adapters against the open connection.
Close the connection with Close() or, better, let a using block dispose it. SqlConnection implements IDisposable, and Dispose() calls Close() internally, so a using block guarantees release even on exceptions.
Key Properties and Methods
ConnectionString: set via the constructor or the property; must be set before Open().
State: a ConnectionState value (Closed, Open, Connecting, etc.) you can inspect before calling Open().
Open() / OpenAsync(): opens the underlying database session. Prefer the async overload in server-side code.
Close() / CloseAsync(): closes the session and returns the connection to the pool. Calling Close() on an already closed connection is harmless.
Dispose() / DisposeAsync(): releases resources; using calls this automatically.
Connection Pooling
The .NET data providers pool connections automatically. When you Close() or Dispose() a connection, the underlying session is returned to a pool keyed by the connection string, and a later Open() with an identical string reuses it instead of making a new server round trip. This is why you should open late, use the connection, and close it as soon as possible — pooling makes short-lived connections cheap. Adding Pooling=true (the default) or Max Pool Size=100 (the default) to the connection string tunes this behavior. Note that Close() returns the connection to the pool; it does not destroy it.
Code Example
using System.Data;
using Microsoft.Data.SqlClient;
// Connection string is normally read from IConfiguration, not hard-coded.
string connectionString = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true;Pooling=true;Max Pool Size=100";
// The using block disposes the connection, which closes it, even on error.
using SqlConnection connection = new(connectionString);
await connection.OpenAsync(); // Open before any command or transaction
using SqlCommand command = new("SELECT Name FROM Customers WHERE Region = @region", connection)
{
CommandType = CommandType.Text
};
command.Parameters.Add("@region", SqlDbType.NVarChar, 50).Value = "West";
await using SqlDataReader reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
string name = reader.GetString(0); // Column ordinal 0
Console.WriteLine(name);
}
// No explicit Close() needed: using disposes the connection here.
Best Practices
Always wrap the connection in a using block so it is disposed even when a command throws.
Prefer OpenAsync() and ExecuteReaderAsync() in web applications to avoid blocking thread-pool threads.
Store the connection string in configuration (appsettings.json with IConfiguration, or ConfigurationManager.ConnectionStrings) — never hard-code credentials.
Open as late as possible and close as early as possible; rely on pooling rather than holding a connection open “for efficiency.”
Do not share a single open connection across concurrent tasks — SqlConnection is not thread-safe. Create one per logical operation.
In .NET Framework 4.8, System.Data.SqlClient is the original provider; new code should still use Microsoft.Data.SqlClient, which is the actively supported successor.
Common Pitfalls
Forgetting to close a connection leaks pooled sessions until garbage collection, which can exhaust the pool and raise timeout errors.
Calling Open() twice without closing throws an InvalidOperationException.
Using System.Data.SqlClient in a .NET 8 project pulls in the legacy, unsupported provider; switch the package reference to Microsoft.Data.SqlClient.
23
What are the distinct responsibilities, return types, and typical use cases for the ExecuteNonQuery, ExecuteScalar, and ExecuteReader methods on an ADO.NET Command object?
KnownRevise
The ExecuteNonQuery, ExecuteScalar, and ExecuteReader methods on the SqlCommand object define how SQL commands are executed and what data is returned. ExecuteNonQuery returns the count of affected rows for modification statements, ExecuteScalar retrieves a single value, and ExecuteReader streams a forward-only result set for multiple rows.
Executes INSERT, UPDATE, DELETE, or DDL statements
int (rows affected; -1 for statements like CREATE TABLE)
Data modification, schema changes
ExecuteScalar
Returns the first column of the first row of a result set
object
Aggregate values (COUNT, SUM), single lookups
ExecuteReader
Streams a result set from the server
SqlDataReader (forward-only, read-only)
Processing multiple rows with low memory pressure
ExecuteNonQuery: Executes INSERT, UPDATE, DELETE, or DDL statements. It returns an int representing the number of rows affected. For statements like CREATE TABLE, the return value is typically -1.
ExecuteScalar: Executes a query and returns the value of the first column of the first row in the result set as an object. All other rows and columns are ignored. It is ideal for aggregate functions like COUNT or SUM.
ExecuteReader: Executes a query and returns a SqlDataReader. This provides a high-performance, forward-only, read-only stream of rows. It requires an open connection while reading.
When to Use Each Method
Use ExecuteNonQuery when modifying data or schema where row counts matter, such as updating user status or deleting records.
Use ExecuteScalar when you need exactly one value, such as checking if a username exists or retrieving an identity key after an insert.
Use ExecuteReader when processing multiple rows, such as listing orders or generating a report, especially when memory efficiency is critical compared to loading a DataSet.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public class CommandExamples
{
public void RunExamples(string connectionString)
{
using var connection = new SqlConnection(connectionString);
connection.Open();
// ExecuteNonQuery: Update data
using (var updateCmd = new SqlCommand(
"UPDATE Users SET IsActive = 0 WHERE LastLogin < @date", connection))
{
updateCmd.Parameters.Add("@date", SqlDbType.DateTime).Value = DateTime.Now.AddYears(-1);
int rowsAffected = updateCmd.ExecuteNonQuery();
Console.WriteLine($"Rows updated: {rowsAffected}");
}
// ExecuteScalar: Get count
using (var countCmd = new SqlCommand(
"SELECT COUNT(*) FROM Users WHERE IsActive = @active", connection))
{
countCmd.Parameters.Add("@active", SqlDbType.Bit).Value = true;
object result = countCmd.ExecuteScalar();
int count = result != DBNull.Value ? (int)result : 0;
Console.WriteLine($"Active users: {count}");
}
// ExecuteReader: Stream data
using (var readCmd = new SqlCommand(
"SELECT Id, Name FROM Users WHERE IsActive = @active", connection))
{
readCmd.Parameters.Add("@active", SqlDbType.Bit).Value = true;
using (var reader = readCmd.ExecuteReader())
{
while (reader.Read())
{
int id = reader.GetInt32(0);
string name = reader.GetString(1);
Console.WriteLine($"{id}: {name}");
}
}
}
}
}
Best Practices and Common Pitfalls
Dispose Resources: Always wrap SqlDataReader and SqlCommand in using statements to prevent connection leaks.
Handle Nulls: ExecuteScalar returns C# null if the result set is empty, and DBNull.Value if the first column of the first row contains a database NULL; check before casting to avoid InvalidCastException.
Parameter Types: Specify SqlDbType explicitly when adding parameters instead of using AddWithValue to prevent type inference issues and plan cache pollution.
Connection State: Ensure the connection is open before calling any execute method; otherwise, an InvalidOperationException is thrown.
Streaming: Prefer ExecuteReader over DataAdapter for large datasets to reduce memory pressure, as it streams rows rather than loading them all into memory.
Async I/O: For scalable server code, prefer the async counterparts ExecuteNonQueryAsync, ExecuteScalarAsync, and ExecuteReaderAsync to avoid blocking threads.
24
In the ADO.NET architecture, what functionality do the IDbConnection and IDbCommand interfaces provide to support database provider independence?
KnownRevise
The IDbConnection and IDbCommand interfaces define the core contracts for managing database sessions and executing statements in ADO.NET, enabling provider-agnostic data access by decoupling application logic from specific database drivers. By coding against these interfaces rather than concrete classes like SqlConnection, developers can switch database providers with minimal changes to the data access layer.
Read full answerHide full answer
Applies to.NET 8, ADO.NET (System.Data)
Core Purpose and Abstraction
These interfaces reside in the System.Data namespace and define the minimum capabilities required for any ADO.NET provider implementation. IDbConnection manages the lifecycle of a database session, including opening and closing connections, creating transactions, and generating command objects. IDbCommand represents a SQL statement or stored procedure, handling command text, execution timeouts, parameter collections, and execution methods such as ExecuteReader and ExecuteScalar. This abstraction allows the same business logic to operate across SQL Server, PostgreSQL, Oracle, or SQLite without rewriting query execution code.
Interface Contracts and Members
IDbConnection: Exposes properties like ConnectionString, State, and Database, alongside methods such as Open(), Close(), CreateCommand(), and BeginTransaction(). It ensures that any provider offers a consistent way to establish a secure session.
IDbCommand: Defines CommandText, CommandType, Transaction, and Parameters. It standardizes how queries are prepared and executed, returning IDataReader for streaming results or scalar values for single-value queries.
IDataParameter: Commands use this interface for parameters, ensuring values are passed safely to prevent SQL injection, though provider-specific types (like SqlDbType) often require casting to the concrete provider type.
Provider Implementations
Concrete classes implement these interfaces to bridge the gap between the generic contract and the specific database protocol. For example, Microsoft.Data.SqlClient.SqlConnection implements IDbConnection for SQL Server, while Npgsql.NpgsqlConnection does the same for PostgreSQL. When an application instantiates a connection via a factory pattern or dependency injection, it receives the interface reference. This polymorphism means swapping a database engine often requires only changing the configuration string and the concrete type instantiation, leaving the query logic untouched. Note that because IDbCommand exposes no asynchronous members and its Parameters collection is typed as IDataParameterCollection, provider-specific casting (for example to SqlCommand) is needed when explicit parameter types such as SqlDbType must be set.
Best Practices and Pitfalls
Avoid Hardcoding Providers: Use DbProviderFactories or dependency injection to resolve concrete types, keeping the code truly provider-agnostic.
Handle Nulls Correctly: A C# null assigned to a parameter value is treated as “not supplied” by ADO.NET. Use DBNull.Value to send a database NULL.
Dispose Resources: Always wrap connections and commands in using statements to ensure network resources and handles are released promptly.
Prefer DbCommand for New Code: In .NET 8, System.Data.Common.DbCommand is generally recommended over IDbCommand because it supports asynchronous methods like ExecuteReaderAsync, whereas IDbCommand only offers synchronous execution.
Security: Never concatenate strings into CommandText. Always use parameters to prevent SQL injection, even when coding against interfaces.
25
How do ExecuteScalar, ExecuteReader, and ExecuteXmlReader differ regarding their return types and intended scenarios when executing commands in ADO.NET?
KnownRevise
ExecuteScalar retrieves a single scalar value, ExecuteReader provides a forward-only stream of result sets, and ExecuteXmlReader returns an XML representation of the results. Selecting the appropriate method depends on whether the application requires a single aggregate value, iterative row access, or direct XML streaming.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x
Return Types and Behavior
Method
Return Type
Data Shape
Connection State
ExecuteScalar
object
First column of the first row only
Busy until method returns
ExecuteReader
SqlDataReader
Forward-only stream of rows and columns
Busy while reader is open
ExecuteXmlReader
XmlReader
XML stream (requires FOR XML in query)
Busy while reader is open
ExecuteScalar ignores additional rows and columns. ExecuteReader allows access to metadata and multiple result sets if enabled. ExecuteXmlReader transforms the rowset into XML format on the server side before streaming.
Typical Use Cases
ExecuteScalar: Best for aggregate queries such as COUNT(*), SUM(), or retrieving an identity value after an insert. It minimizes network traffic when only one value is needed.
ExecuteReader: Suitable for processing large result sets row-by-row without loading the entire dataset into memory. It is preferred over DataAdapter for read-only streaming scenarios.
ExecuteXmlReader: Used when legacy systems consume XML directly from the database. Modern architectures typically prefer retrieving standard rows and serializing to JSON or XML in the application layer.
Connection and Resource Management
All three methods require an open SqlConnection. ExecuteReader and ExecuteXmlReader hold the connection open for the duration of the reader’s lifecycle. Attempting to execute another command on the same connection while a reader is open will throw an exception unless Multiple Active Result Sets (MARS) is enabled in the connection string (MultipleActiveResultSets=true). Always dispose of readers and connections promptly to return connections to the pool.
Best Practices and Pitfalls
Null Handling: ExecuteScalar returns null if the result set is empty, but returns DBNull.Value if the first column of the first row contains a SQL NULL. Cast carefully to avoid InvalidCastException.
Parameter Types: Always declare parameters with explicit types, e.g., command.Parameters.Add("@id", SqlDbType.Int).Value = userId. Never use AddWithValue, as it infers types and can degrade query plan performance.
Disposal: Wrap SqlDataReader and XmlReader in using statements. Failing to close them explicitly leaks connections and may exhaust the connection pool.
XML Deprecation: While ExecuteXmlReader is functional, reliance on server-side XML generation is considered legacy. Prefer standard rowsets for better testability and flexibility in modern .NET development.
26
Explain how to call a SQL Server stored procedure from ADO.NET, including how the procedure is defined and how parameters are passed and results read.
KnownRevise
A stored procedure is called from ADO.NET by setting a SqlCommand‘s CommandType to CommandType.StoredProcedure, giving it the procedure name, adding each parameter with an explicit SqlDbType, and then executing it with ExecuteNonQuery, ExecuteReader or ExecuteScalar depending on what it returns. ADO.NET only sends the procedure name and the parameter values over the wire, so the database compiles and caches the execution plan, and parameterized calls protect against SQL injection.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and the `Microsoft.Data.SqlClient` NuGet package (the supported successor to the legacy `System.Data.SqlClient`).
How It Works
Command setup: create a SqlConnection, open it, then create a SqlCommand whose CommandText is the procedure name (e.g. dbo.GetOrdersByCustomer) and whose CommandType is CommandType.StoredProcedure. You do not write EXEC or CALL; the provider handles that.
Parameters: each parameter is added with command.Parameters.Add("@name", SqlDbType.Type). Set .Size for variable-length types such as NVarChar and VarChar, and .Precision/.Scale for Decimal, so the provider sends a correct definition. Then assign .Value. Sending C# null means “not supplied”; send DBNull.Value when you want a database NULL.
Direction: the default is ParameterDirection.Input. Use Output or InputOutput for values the procedure returns through parameters, and read .Value after execution (cast from object).
Execution method: ExecuteNonQuery for procedures that do not return a result set (it returns rows affected), ExecuteReader for a result set streamed through a SqlDataReader, and ExecuteScalar for a single scalar such as a count or identity value.
By name in SQL Server; the parameter token must match the procedure definition
Injection safety
Values are sent as data, never concatenated into SQL text
Return value
Use a parameter with Direction = ParameterDirection.ReturnValue for RETURN codes
Transactions
Pass an existing SqlTransaction into the SqlCommand constructor or set Transaction before executing
Code Example
First, the procedure in SQL Server:
CREATE PROCEDURE dbo.GetOrdersByCustomer
@CustomerId INT,
@FromDate DATETIME,
@Status NVARCHAR(20) = NULL
AS
SELECT OrderId, OrderDate, TotalAmount
FROM dbo.Orders
WHERE CustomerId = @CustomerId
AND OrderDate >= @FromDate
AND (@Status IS NULL OR Status = @Status)
ORDER BY OrderDate;
Then the C# call:
using System;
using System.Collections.Generic;
using System.Data; // SqlDbType, CommandType
using System.Threading.Tasks; // Task
using Microsoft.Data.SqlClient; // SqlConnection, SqlCommand, SqlDataReader
public static async Task<List<Order>> GetOrdersAsync(
string connectionString, int customerId, DateTime fromDate, string? status)
{
var orders = new List<Order>();
// Connection is opened before the command executes and disposed on exit
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var command = new SqlCommand("dbo.GetOrdersByCustomer", connection)
{
CommandType = CommandType.StoredProcedure
};
// Explicit types and sizes; never use AddWithValue
command.Parameters.Add("@CustomerId", SqlDbType.Int).Value = customerId;
command.Parameters.Add("@FromDate", SqlDbType.DateTime).Value = fromDate;
// C# null means "not supplied"; DBNull.Value sends an explicit SQL NULL
var statusParam = command.Parameters.Add("@Status", SqlDbType.NVarChar, 20);
statusParam.Value = string.IsNullOrEmpty(status) ? DBNull.Value : status;
try
{
// One open reader per connection; stream the rows while reading
using var reader = await command.ExecuteReaderAsync();
while (await reader.ReadAsync())
{
orders.Add(new Order
{
// IsDBNull avoids InvalidCastException on NULL columns
OrderId = reader.GetInt32(0),
OrderDate = reader.GetDateTime(1),
TotalAmount = reader.IsDBNull(2) ? 0m : reader.GetDecimal(2)
});
}
}
catch (SqlException ex)
{
// Log the error; include procedure name and state for diagnostics
Console.WriteLine($"SQL error {ex.Number}: {ex.Message}");
throw;
}
return orders;
}
public class Order
{
public int OrderId { get; set; }
public DateTime OrderDate { get; set; }
public decimal TotalAmount { get; set; }
}
Best Practices and Common Pitfalls
Always parameterize: never build the procedure call or its arguments with string concatenation; that defeats the injection protection the provider gives you for free.
Prefer the async methods (OpenAsync, ExecuteReaderAsync, ReadAsync) in web or UI code so blocked database calls do not tie up threads.
Keep the connection short-lived: open it last, close it first, and rely on using rather than manual Close.
Match types exactly: an AddWithValue-free, explicitly typed parameter avoids implicit conversions that can defeat indexes (for example NVarChar compared against a VarChar column).
Handle NULLs in both directions: send DBNull.Value for SQL NULL, and check reader.IsDBNull(i) before reading a nullable column.
Do not leave connections open across readers: nest a second command inside the loop only if you enabled MARS, or open a second connection.
27
Can you explain the concept of prepared statements and demonstrate how to implement them using ADO.NET?
KnownRevise
Prepared statements are SQL commands precompiled by the database server to separate executable logic from user-supplied data. In ADO.NET, this mechanism is implemented using parameterized queries with the SqlCommand class, ensuring values are treated strictly as data rather than executable code.
A prepared statement defines the SQL structure once, allowing the database engine to compile an execution plan before specific values are bound. When parameters are used, the server caches the plan and reuses it for subsequent executions with different values. This contrasts with string concatenation, where every variation generates a new plan and exposes the application to injection risks.
Implementation in ADO.NET
To use prepared statements, create a SqlCommand with parameter placeholders (e.g., @Id) in the command text. Define each parameter explicitly via the Parameters collection, specifying the SqlDbType and size where applicable. ADO.NET does not prepare statements automatically; you must call SqlCommand.Prepare() explicitly to force the server to compile the plan before execution, which can optimize performance for loops or batch operations. Note that Prepare() consumes an extra network round-trip, so it is only beneficial when the same command is executed many times.
Security and Performance Benefits
SQL Injection Prevention: Parameters are escaped automatically by the provider, preventing malicious input from altering query structure.
Plan Reuse: The database caches the execution plan, reducing compilation overhead for frequently run queries.
Type Safety: Explicit typing ensures data matches column definitions, reducing conversion errors and implicit casts.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
public void GetUser(int userId, string connectionString)
{
const string sql = "SELECT Name, Email FROM Users WHERE Id = @Id AND Status = @Status";
using (var connection = new SqlConnection(connectionString))
{
connection.Open();
using (var command = new SqlCommand(sql, connection))
{
// Explicit type and size for parameters
command.Parameters.Add("@Id", SqlDbType.Int).Value = userId;
command.Parameters.Add("@Status", SqlDbType.NVarChar, 10).Value = "Active";
// Optional: Explicitly prepare for repeated execution
// command.Prepare();
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
Console.WriteLine(reader["Name"]);
}
}
}
}
}
Best Practices and Pitfalls
Avoid AddWithValue: Never use Parameters.AddWithValue as it infers types incorrectly (e.g., mapping strings to NVarChar(MAX)), hurting performance and preventing plan reuse.
Specify String Size: Always define the size for VarChar or NVarChar parameters to match the column schema, avoiding unnecessary memory allocation.
Handle Nulls: Use DBNull.Value instead of C# null when a parameter represents a database NULL, since a null value is treated as an unset parameter.
Dispose Resources: Wrap SqlConnection, SqlCommand, and SqlDataReader in using statements to prevent connection leaks and ensure timely cleanup.
Explicit Prepare: Use Prepare() only when executing the same command many times in a single session; otherwise the extra round-trip is not worth it, and SQL Server can still reuse plans for parameterized queries without explicit preparation.
28
Explain the way a DataAdapter handles opening and closing connections to a data source during data retrieval and updates.
KnownRevise
A DataAdapter bridges a DataSet (or DataTable) and a data source, and it manages the connection lifecycle for you: it opens the connection only while a Fill or Update operation needs it and closes it again when the operation finishes, unless the connection was already open before the call.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and `Microsoft.Data.SqlClient` (the classes described are `System.Data.Common.DbDataAdapter` and `Microsoft.Data.SqlClient.SqlDataAdapter`; the same behaviour applies to other ADO.NET providers such as `Oracle.ManagedDataAccess.Core`).
How It Works
The DataAdapter never holds a connection open between operations. Its behaviour follows a simple rule:
You assign command objects to its four command properties: SelectCommand, InsertCommand, UpdateCommand and DeleteCommand.
You assign a connection to each of those commands (the DataAdapter reads the connection from the commands, and all commands should share one connection).
When you call Fill, the adapter checks the connection state:
If it is closed, it opens it, fetches the data, and closes it when done.
If it is open, it uses it as-is and leaves it open afterwards.
When you call Update, the same open/close logic applies while it walks the changed rows and executes the matching command for each DataRowState (Added, Modified, Deleted).
Because the adapter inspects state rather than blindly closing, you can safely wrap several adapter calls inside one open connection or inside one transaction when you need them to run as a unit.
Key Features
Connection ownership belongs to the command, not the adapter. The adapter has no Connection property of its own; it uses SelectCommand.Connection and the others.
Automatic open/close on Fill. This is why Fill works even if you never call connection.Open().
Automatic open/close on Update. The adapter opens the connection, submits the per-row changes, and closes it again.
Transaction awareness. If you set SelectCommand.Transaction (and the same transaction on the other commands), the adapter enlists in it instead of starting an implicit one.
AcceptChanges after each row is saved.DataAdapter.Update calls AcceptChanges on each row it successfully writes, so those rows are no longer marked as changed. Do not call AcceptChanges yourself after Update — that would discard any remaining changes and is unnecessary.
Table and schema mapping.TableMappings and MissingSchemaAction control how result sets land in DataSet tables, but they do not affect connection handling.
Explicitly Managing the Connection vs. Letting the Adapter Do It
Aspect
Adapter-managed connection
Manually opened connection
Code before the call
Just create the connection and command
connection.Open()
Connection state after Fill/Update
Closed (if it started closed)
Still open
Best fit
Single, self-contained read or write
Multiple operations sharing one connection or transaction
Risk
None for correctness; connection is always released
Forgetting to close leaks the connection
The practical rule: let the adapter manage the connection for simple one-shot scenarios, and open it yourself (inside a using block) when you need a transaction, several commands, or multiple adapter calls to share the same connection.
Best Practices and Common Pitfalls
Always wrap the connection in a using even when the adapter closes it; the using guarantees disposal if an exception occurs before Fill or Update runs.
Share one connection object across all four commands. Using different connections for SelectCommand and UpdateCommand is legal but defeats connection pooling efficiency and breaks transaction enlistment.
Enlist commands in a transaction explicitly when Update must be all-or-nothing: assign the same SqlTransaction to InsertCommand, UpdateCommand and DeleteCommand, and open the connection before creating the transaction.
Do not call AcceptChanges after Update. The adapter already did it per saved row; calling it again silently discards any rows that failed to save.
Remember that Fill leaves an already-open connection open. If you open it, you own closing it.
29
Describe the behavior of the DataAdapter.Fill method and the mechanism it uses to load data into a DataSet or DataTable.
KnownRevise
The Fill method executes the SelectCommand associated with the DataAdapter and copies the returned rows into the provided DataSet or DataTable, inferring the table schema automatically if the target does not already exist. It manages the database connection lifecycle for you, opening the connection only if it is closed and restoring its original state when the operation completes.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET and Microsoft.Data.SqlClient
How It Works
Connection Management: If the connection is closed when Fill is called, the method opens it, reads the data, and closes it. If the connection is already open, Fill leaves it open when finished so your surrounding code retains control.
Data Retrieval: Internally, it executes the command and uses an IDataReader to stream rows into the target object, mapping each result row to a DataRow without holding the entire raw result set in memory separately.
Return Value: It returns an int indicating the number of rows successfully added to or refreshed in the target object.
Schema Inference and Table Mapping
Automatic Schema: If the target DataTable does not exist in the DataSet, Fill creates it based on the column names and data types present in the result set.
Existing Schema: If the table already exists, incoming data is merged into existing rows that match the primary key, or appended as new rows if no key is defined.
Primary Keys: By default, Fill does not retrieve primary key or constraint information. Use MissingSchemaAction.AddWithKey or call FillSchema beforehand if you need constraint metadata for later updates.
Handling Multiple Result Sets
DataSet: If the SelectCommand returns multiple result sets, Fill creates multiple tables in the DataSet, naming them Table, Table1, Table2, and so on.
DataTable: When filling a single DataTable, only the first result set is consumed; any subsequent results returned by the command are ignored.
Best Practices and Common Pitfalls
Connection State: Do not assume the connection is closed after Fill returns; verify the state if you opened it manually before calling the method.
NULL Values: Accessing a database NULL with DataRow.Field<int>() throws an exception; use Field<int?>() for nullable columns to handle DBNull safely.
Schema Overhead: Calling FillSchema adds an extra round trip to the database; prefer passing MissingSchemaAction.AddWithKey directly to Fill when key information is required.
Resource Disposal: Wrap SqlConnection, SqlCommand, and SqlDataAdapter in using statements to ensure network resources and handles are released promptly.
30
Explain how to persist changes made in a DataSet back to the database.
KnownRevise
Changes in a DataSet (or DataTable) are written back to the database by calling DataAdapter.Update, which inspects the RowState of each row (Added, Modified, Deleted) and executes the matching InsertCommand, UpdateCommand or DeleteCommand that you configured on the adapter.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (the same API exists in .NET Framework with `System.Data.SqlClient`).
How It Works
DataAdapter.Fill loads rows into a DataTable; each row starts with RowState = Unchanged.
Your code edits the rows: adding, modifying or deleting them changes RowState accordingly.
DataAdapter.Update(table) walks the rows in the order they appear in the table’s row collection, matches each row’s RowState to one of the three action commands, fills the command parameters from the row, and executes the command.
For each row it successfully saves, Update calls AcceptChanges on that row, resetting RowState to Unchanged. You should not call AcceptChanges yourself after Update, because that would erase the change tracking that Update has already processed.
Rows whose state is Unchanged are skipped, and DataRow values are sent as parameters, which prevents SQL injection.
Key Components
SelectCommand: the query used by Fill; its result set defines the table schema and, when the source column names match, the default SourceColumn mappings for the other commands.
InsertCommand / UpdateCommand / DeleteCommand: one command per kind of change. Any command you leave null means that kind of change cannot be saved.
SqlParameter.SourceColumn: tells the adapter which DataTable column supplies the parameter’s value. Without it, the parameter gets no value from the row.
SqlParameter.SourceVersion: defaults to Current, which is right for INSERT/UPDATE values. For an optimistic-concurrency WHERE clause that compares against the original values, set SourceVersion = DataRowVersion.Original.
SqlCommandBuilder: a convenience that generates the three action commands from a single-table SelectCommand at runtime. It is fine for quick, single-table scenarios, but hand-written commands give you control over concurrency, stored procedures and performance, and are preferred in production code.
DataTable.GetChanges(): returns a copy containing only changed rows, useful for validating or sending a subset to a server before calling Update on the original table.
DataAdapter.ContinueUpdateOnError: when true, a failing row is skipped (its error is recorded in RowError) and the remaining rows are still saved; when false (default), the first error throws and the update stops.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
class Program
{
static void Main()
{
var connectionString = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=true;";
using var connection = new SqlConnection(connectionString);
using var adapter = new SqlDataAdapter();
adapter.SelectCommand = new SqlCommand("SELECT Id, Name, Price FROM Products", connection);
adapter.InsertCommand = new SqlCommand(
"INSERT INTO Products (Name, Price) VALUES (@name, @price)", connection);
adapter.InsertCommand.Parameters.Add("@name", SqlDbType.NVarChar, 100).SourceColumn = "Name";
var insPrice = adapter.InsertCommand.Parameters.Add("@price", SqlDbType.Decimal);
insPrice.Precision = 10; insPrice.Scale = 2; insPrice.SourceColumn = "Price";
adapter.UpdateCommand = new SqlCommand(
"UPDATE Products SET Name = @name, Price = @price WHERE Id = @id", connection);
adapter.UpdateCommand.Parameters.Add("@name", SqlDbType.NVarChar, 100).SourceColumn = "Name";
var updPrice = adapter.UpdateCommand.Parameters.Add("@price", SqlDbType.Decimal);
updPrice.Precision = 10; updPrice.Scale = 2; updPrice.SourceColumn = "Price";
adapter.UpdateCommand.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
adapter.DeleteCommand = new SqlCommand("DELETE FROM Products WHERE Id = @id", connection);
adapter.DeleteCommand.Parameters.Add("@id", SqlDbType.Int).SourceColumn = "Id";
var table = new DataTable();
adapter.Fill(table);
// Modified: change an existing row
if (table.Rows.Count > 0)
{
table.Rows[0]["Price"] = 24.95m;
}
// Added: insert a new row (Id 0 is a placeholder; the INSERT handles identity)
table.Rows.Add(0, "New Gadget", 19.95m);
// Deleted: mark an existing row for removal
if (table.Rows.Count > 1)
{
table.Rows[1].Delete();
}
adapter.Update(table); // saves each row and calls AcceptChanges on the saved rows
}
}
Best Practices and Common Pitfalls
Always use parameters with explicit types and sizes, never string concatenation or AddWithValue, which infers types from values and can cause bad query plans or conversion errors.
Set SourceColumn (and SourceVersion for original values) on every parameter of the action commands; a missing mapping is a common reason an Update silently writes nothing or fails.
Do not call AcceptChanges before Update: that marks every row Unchanged, so nothing is saved. Call RejectChanges only to discard local edits.
Guard against lost updates: in a multi-user system, add the original column values to the WHERE clause of UpdateCommand and DeleteCommand and check the returned row count; a DBConcurrencyException means the row changed since you loaded it.
Wrap related updates in a SqlTransaction when a DataSet spans several tables, so the whole batch commits or rolls back together.
Remember the disconnected trade-off: this pattern is ideal for two-tier, occasionally connected clients; for server-side or web work, prefer EF Core 8 or direct SqlCommand calls, which avoid holding a full result set in memory.
31
Explain the concept of a DataSet schema in ADO.NET, including the approaches for defining it and how it is utilized during data operations.
KnownRevise
A DataSet schema defines the structural metadata of a disconnected, in-memory data cache in ADO.NET, including table names, column data types, primary keys, constraints, and relationships between tables. It can be defined programmatically, loaded from XML/XSD, or inferred from a database, and it is used to enforce data integrity and enable structured, relation-aware navigation without a continuous connection.
Read full answerHide full answer
Applies to.NET 8, ADO.NET (System.Data)
What Is a DataSet Schema
The schema is the blueprint held within a DataSet object. It consists of a collection of DataTable objects, each containing DataColumn definitions and Constraint objects such as UniqueConstraint and ForeignKeyConstraint. Unlike a database schema, it exists entirely in memory, which allows applications to manipulate data offline and later reconcile changes. Hierarchical data is supported through DataRelation objects that link parent and child tables on matching columns, enabling navigation such as GetChildRows.
Approaches to Defining a Schema
There are three main ways to establish a DataSet schema:
Approach
How it works
When to use
Programmatic
Build DataTable, DataColumn, Constraint and DataRelation objects directly in code
Dynamic structures driven by configuration or user input; no database connection needed
XML/XSD
ReadXmlSchema imports an XSD; WriteXmlSchema exports one; ReadXml/WriteXml handle schema and data together
Data exchange across platforms or application boundaries
Database-derived
DataAdapter.FillSchema or Fill infers the schema from query results and column metadata
When the in-memory shape must match an existing database
Programmatic definition gives full control over the structure. XML interoperability lets structure survive transmission, which made DataSet a common choice in older web services. Database-derived schemas reduce duplication but couple the in-memory shape to the current database design.
How the Schema Is Used During Data Operations
Once defined, the schema governs how data can be changed:
Constraint enforcement: With EnforceConstraints set to true (the default), foreign-key violations, unique-constraint breaches, and nulls in non-nullable columns throw exceptions as changes are made, not when they are sent to the database.
Relationship navigation: DataRelation objects let you traverse parent and child rows without writing joins.
Change tracking: The schema underpins DataRow.RowState tracking, so DataAdapter.Update can apply the correct INSERT, UPDATE, and DELETE operations.
Typed access: A typed DataSet generated from the schema exposes strongly typed properties such as row.ColumnName with compile-time checking; untyped DataSets use row["ColumnName"] and suit dynamic scenarios where the structure is not known at compile time.
Best Practices and Pitfalls
Avoid binary serialization: Do not set DataSet.RemotingFormat = Binary; it relies on BinaryFormatter, which is obsolete and removed in .NET 9. Use XML or JSON instead.
Nullable handling: DataRow.Field<int>() throws on DBNull; use Field<int?>() for columns that allow database NULLs.
Memory overhead: A DataSet carries significantly more memory overhead than a DbDataReader. Use it only when disconnected manipulation, multiple related tables, or change tracking is required.
Bulk loads: Temporarily set EnforceConstraints to false when bulk-loading interdependent tables to avoid ordering issues, then re-enable it.
Untrusted schemas: When loading a schema from an external source via ReadXmlSchema, validate the XSD first to reduce the risk of denial-of-service attacks via complex type definitions.
32
What strategies can be used to optimize the performance of DataSet instances within an ADO.NET data access layer?
KnownRevise
Optimizing DataSet performance in ADO.NET primarily involves reducing overhead from change tracking, schema inference, and serialization, while often favoring lighter data structures like DataTable or DbDataReader for read-only scenarios. Key strategies include disabling constraints during bulk loads and avoiding unnecessary schema retrieval.
Read full answerHide full answer
Applies to.NET 8 and ADO.NET
Change Tracking and Constraints
A DataSet maintains state for every row to support updates and rollbacks, which consumes memory and CPU. When loading large volumes of data where immediate validation is unnecessary, disable constraint checking temporarily. Set dataSet.EnforceConstraints = false before calling DataAdapter.Fill and re-enable it afterward. This prevents the engine from validating foreign key relationships and unique constraints on every inserted row. Note that DataAdapter.Update automatically calls AcceptChanges on each saved row, so manual calls afterward are redundant and should be avoided.
Schema Handling and Fill Options
Inferring schema dynamically during a fill operation adds significant latency. If the schema is known, define the DataTable structure beforehand and set adapter.MissingSchemaAction = MissingSchemaAction.AddWithKey or Add appropriately. Avoid using FillSchema unless the structure is truly unknown, as it executes an additional query or derives metadata unnecessarily. When passing data across tiers, prefer XmlWriteMode.IgnoreSchema if the receiver already knows the contract, reducing payload size. Remember that DataRow.Field<int>() throws on database NULLs; use Field<int?>() for nullable columns to prevent runtime exceptions during processing.
Serialization and Remoting
Historically, DataSet supported binary serialization via RemotingFormat = Binary, but this relies on BinaryFormatter. BinaryFormatter is obsolete and removed in .NET 9, making this approach insecure and unsupported for modern applications. Instead, serialize data using JSON or explicit XML schemas (XmlWriteMode.WriteSchema) only when necessary. For high-performance internal communication, consider passing lightweight DTOs (Data Transfer Objects) rather than full DataSet graphs, which carry substantial metadata overhead.
Architectural Alternatives
Often the best optimization is avoiding DataSet entirely. The table below compares the common disconnected and connected options:
Option
Best For
Overhead
DataSet
Multiple related tables, offline manipulation, legacy integration
Highest (schema, relations, change tracking)
DataTable
Single-table relational operations, no relations needed
Moderate (schema + change tracking)
DbDataReader
Forward-only, read-only streaming
Lowest (no caching)
POCO / DTO (EF Core, Dapper)
Modern domain-driven designs, high-performance mapping
Low (no ADO.NET tracking)
Use DataTable when relational features like multiple tables and relations are not required, as it reduces object graph complexity. For forward-only read scenarios, DbDataReader provides the lowest memory footprint and fastest access since it streams data without caching. If you need disconnected data but want modern performance, map results to POCOs using EF Core or a micro-ORM like Dapper. Reserve DataSet for scenarios requiring complex offline manipulation, multiple related tables, or legacy integration where its specific change-tracking semantics are required.
Common Pitfalls
Do not treat a DataSet as a generic collection for simple lists; the overhead is unjustified. Never assume DataRow values are non-null without checking or using nullable generics. Avoid holding DataSet instances in long-lived static caches, as they prevent garbage collection of large object heaps. Finally, ensure distributed transaction settings are configured correctly if using DataSet updates within a TransactionScope, noting that implicit distributed transactions require explicit enablement on .NET 7+ Windows environments.
33
What practices do you follow to make an ADO.NET application scale well under load?
KnownRevise
A scalable ADO.NET application relies primarily on connection pooling combined with asynchronous I/O, streaming reads, and disciplined resource disposal. These practices let a fixed pool of connections serve many concurrent requests without exhausting database resources or blocking thread-pool threads.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient
Connection Pooling
Pooling is enabled by default in Microsoft.Data.SqlClient; connections with identical connection strings share one pool.
The default Max Pool Size is 100 per connection string. If concurrent demand exceeds this, requests queue waiting for a free connection, which looks like a hang.
Tune pooling in the connection string: Min Pool Size=5; Max Pool Size=200; Connection Lifetime=0. Set Min Pool Size only after measuring steady-state load, since it pre-allocates connections.
On Linux/macOS, pooling behaves the same as on Windows for Microsoft.Data.SqlClient; the legacy System.Data.SqlClient is in maintenance mode and should not be used for new development in .NET 8.
Async I/O and Streaming Reads
Use OpenAsync, ExecuteReaderAsync, and ReadAsync so threads return to the pool while waiting on the database. Never block with .Result or .Wait(), which causes thread-pool starvation under load.
SqlDataReader streams rows one at a time instead of buffering the whole result set. Prefer it over filling a DataSet or DataTable when you process rows sequentially.
Set CommandTimeout explicitly (default is 30 seconds) so runaway queries fail fast instead of holding connections indefinitely.
Efficient Batch and Bulk Operations
For bulk inserts, use SqlBulkCopy instead of executing one INSERT per row; it moves data in batches with far fewer round trips.
By default SqlBulkCopy does not check constraints or fire triggers; pass SqlBulkCopyOptions.CheckConstraints or FireTriggers when you need them.
SqlBulkCopy with UseInternalTransaction commits each batch separately. For all-or-nothing atomicity, create an external SqlTransaction and pass it to the constructor.
Common Pitfalls
AddWithValue: never use it; it forces type inference and can cause plan-cache bloat. Add parameters with an explicit SqlDbType, for example command.Parameters.Add("@id", SqlDbType.Int).Value = id;.
Leaked connections: always wrap SqlConnection, SqlCommand, and SqlDataReader in using (or await using) so pooling can reuse them.
Connection string drift: even a trailing space creates a separate pool. Store the string in one configuration location.
Long transactions: keep transactions short; an open transaction holds its connection and blocks cleanup until committed.
DataTable overuse: loading large result sets into memory per request increases GC pressure; stream with SqlDataReader when possible.
34
What does the isolation level of a transaction control, and which isolation levels are available?
KnownRevise
An isolation level defines how strongly a transaction is shielded from changes made by other concurrent transactions in the same database. It is the dial a developer turns to trade consistency against concurrency: stricter levels block or hide more interference but reduce parallelism, while looser levels allow more transactions to run together at the cost of exposing intermediate or changing data.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient and the `System.Data.IsolationLevel` enum; the same levels exist in .NET Framework 4.8 with `System.Data.SqlClient`.
What It Controls
Every isolation level is defined by which of the following concurrency side effects it permits or prevents:
Dirty read: a transaction reads a row that another transaction has modified but not yet committed; if that transaction rolls back, the reader has data that never existed.
Non-repeatable read: a transaction reads the same row twice and gets different values because another transaction updated or deleted the row and committed in between.
Phantom read: a transaction re-runs a query with the same predicate and finds extra or missing rows because another transaction inserted or deleted matching rows and committed in between.
Lost update and write skew: rarer anomalies that stricter levels or explicit locking also guard against.
The Isolation Levels
The System.Data.IsolationLevel enum exposes these members. SQL Server maps them to its own locking and versioning behaviour as shown.
Isolation level
Enum value
Dirty read
Non-repeatable read
Phantom read
Notes
Read Uncommitted
IsolationLevel.ReadUncommitted
Allowed
Allowed
Allowed
Reads uncommitted data (“dirty read”); SQL Server takes no shared locks and honors only exclusive locks.
Read Committed
IsolationLevel.ReadCommitted
Prevented
Allowed
Allowed
SQL Server default; takes shared locks for the duration of the read and releases them.
Repeatable Read
IsolationLevel.RepeatableRead
Prevented
Prevented
Allowed
Shared locks are held until the transaction commits, so queried rows cannot change.
Serializable
IsolationLevel.Serializable
Prevented
Prevented
Prevented
Strongest locking level; SQL Server places range locks so even matching new rows cannot be inserted.
Snapshot
IsolationLevel.Snapshot
Prevented
Prevented
Prevented
Uses row versioning in tempdb instead of blocking; readers see the committed state as of the transaction’s start. Requires ALTER DATABASE ... SET ALLOW_SNAPSHOT_ISOLATION ON in SQL Server.
Chaos
IsolationLevel.Chaos
Allowed
Allowed
Allowed
Loosely defined “anything can happen” level; not supported by SQL Server.
Unspecified
IsolationLevel.Unspecified
—
—
—
Sent when the provider cannot determine the level; not a level you set deliberately.
How It Is Applied in ADO.NET
The level is chosen when the transaction starts, and it applies to every command enlisted in that transaction:
SqlConnection.BeginTransaction(IsolationLevel) starts a transaction with the requested level on a single connection.
TransactionScope uses the ambient transaction’s level; the default for a SQL Server ambient transaction is Read Committed unless you pass a TransactionOptions structure with an explicit System.Transactions.IsolationLevel.
The level is a request, not a command: the provider and database engine translate it to their own semantics, and some backends silently coerce an unsupported level to the closest supported one.
Choosing a Level
Default to Read Committed for typical OLTP work; it is the SQL Server default and a sound balance of correctness and throughput.
Use Snapshot when read contention is hurting performance and your workload can tolerate readers seeing a slightly older, committed view of the data; watch for the tempdb version-store overhead and update conflicts.
Use Repeatable Read or Serializable only when the business logic genuinely requires those guarantees, because the extra locking scales poorly under high concurrency.
Avoid Read Uncommitted except for deliberate, throwaway diagnostic reads; reading uncommitted data can produce results that are impossible under any correct schedule.
35
Explain how pessimistic and optimistic concurrency control differ in their approach to handling simultaneous data access.
KnownRevise
Pessimistic concurrency control prevents conflicts by locking data for the entire duration of a transaction so other users cannot modify it, whereas optimistic concurrency control allows concurrent reads without locks and detects a conflict only when an update is attempted. The choice between them depends on how often conflicts actually occur in your workload.
Read full answerHide full answer
Applies to.NET 8, EF Core 8, and ADO.NET with Microsoft.Data.SqlClient
Core Difference
Pessimistic: assumes conflicts are likely, so it acquires a lock as soon as data is read and holds it until the transaction commits or rolls back. Other sessions that try to read or write the locked rows must wait.
Optimistic: assumes conflicts are rare, so it reads data without taking locks. Before writing, it verifies that the data has not changed since it was read, typically using a version number, timestamp, or full row comparison.
Pessimistic Concurrency Control
Implemented with explicit transactions and locking, such as SELECT ... WITH (UPDLOCK, HOLDLOCK) in SQL Server or a transaction with an appropriate IsolationLevel.
In ADO.NET you would open a SqlConnection, begin a SqlTransaction, read the row within that transaction, and keep the connection open until the update completes.
Advantages: conflicts are impossible while the lock is held; simple mental model.
Limitations: reduces concurrency, can cause blocking and deadlocks, and holding locks across a round trip (read, user edits, save) is usually impractical in disconnected web applications.
Optimistic Concurrency Control
EF Core 8 implements optimistic checking by default: UPDATE and DELETE statements include all original property values in the WHERE clause, so a row changed by another session yields zero rows affected and throws a DbUpdateConcurrencyException. For efficient conflict detection, configure an explicit concurrency token such as a SQL Server rowversion/timestamp column with .IsRowVersion().
If no row matches (because another session changed it), EF Core throws a DbUpdateConcurrencyException, which your code can catch and resolve by retrying, refreshing the values, or notifying the user.
In raw ADO.NET you do the same manually: include the original RowVersion in the WHERE clause and check RowsAffected to detect a conflict.
Advantages: no locks held during user think time, scales well for web workloads, no deadlocks.
Limitations: conflicts do happen and must be handled; requires a version column or equivalent.
Comparison
Aspect
Pessimistic
Optimistic
Locks during read
Yes
No
Conflict prevention
Blocks other sessions
Detects at write time
Best suited for
High contention, critical financial data
Low-to-moderate contention, web apps
Overhead
Lock management, waiting, possible deadlocks
Version checks, occasional retries
Disconnected scenarios
Poor fit (locks cannot be held)
Natural fit
Deadlock risk
Possible
None
Choosing Between Them
Use optimistic by default in ASP.NET and most line-of-business applications, because requests are short-lived and holding database locks across HTTP requests would exhaust connections.
Use pessimistic only when contention is genuinely high, when a lost update would be catastrophic, or when a multi-step operation must be serialized end to end within a single database transaction.
In EF Core, mark a column as a concurrency token with .IsConcurrencyToken() or .IsRowVersion(); without a token, EF Core uses all original values in the WHERE clause, which is less efficient.
Common Pitfalls
Not handling DbUpdateConcurrencyException: catching it and blindly retrying can overwrite another user’s changes; decide on a resolution strategy (client wins, store wins, or merge).
Using AddWithValue or omitting the version parameter type: always add parameters with an explicit SqlDbType, and for rowversion use SqlDbType.Timestamp with the correct size.
Assuming optimistic means no conflicts: you must still design the UI and API to surface and resolve them.
Holding pessimistic locks across user interaction: this blocks other users and can exhaust the connection pool; keep such transactions as short as possible.
36
What are the recommended strategies for executing batch updates when working with ADO.NET data access?
KnownRevise
Batch updates in ADO.NET minimize database round-trips by grouping multiple modification commands into a single network packet. The primary mechanism for this is configuring the SqlDataAdapter.UpdateBatchSize property, though manual batching within a transaction offers finer control over memory and error handling.
When UpdateBatchSize is set to a value greater than 1, the SqlDataAdapter groups that many INSERT, UPDATE, or DELETE commands before sending them to the server in one round-trip, which reduces network latency compared to executing each row change individually. Setting it to 1 disables batching; setting it to 0 lets the provider create the largest batch it can. If an error occurs within a batch, the outcome depends on the transaction context: without an explicit transaction, previously successful batches stay committed while the failing batch is rolled back, so the operation is not all-or-nothing.
Manual Batching with Transactions
For large datasets, loading everything into a DataSet may consume too much memory. A leaner strategy is to read data in chunks with a SqlDataReader, build SqlCommand objects explicitly with parameterized SQL, and execute them inside a single SqlTransaction, committing only after every chunk succeeds. This avoids DataSet change-tracking overhead, keeps the working set small, and lets you apply custom validation or retry logic between batches. It is also the fallback when a provider’s data adapter does not support batching at all.
Bulk Operations vs. Batch Updates
SqlBulkCopy is optimized for high-volume inserts, not for updating existing rows. The usual pattern for bulk updates is to bulk copy the new data into a staging table and then run a single set-based UPDATE or MERGE statement that joins the staging table to the target. Note that by default SqlBulkCopy does not check constraints or fire triggers; use SqlBulkCopyOptions.CheckConstraints or SqlBulkCopyOptions.FireTriggers when those behaviors are required.
Aspect
SqlDataAdapter batch updates
Manual batching in a transaction
SqlBulkCopy + staging table
Best for
Moderate row counts already tracked in a DataSet/DataTable
Large or streamed data with custom logic
Very large bulk loads
Update support
Yes (INSERT, UPDATE, DELETE)
Yes, via your own SQL
Only inserts; updates need a staging table and set-based SQL
Memory use
Higher (change set held in memory)
Lower (chunks processed and discarded)
Lower for the load itself
Atomicity
Needs an explicit SqlTransaction for all-or-nothing
Natural with a single SqlTransaction
Needs an external SqlTransaction for all-or-nothing
Best Practices and Pitfalls
Explicit parameters: always add parameters with an explicit SqlDbType and size, e.g. command.Parameters.Add("@id", SqlDbType.Int).Value = id; never use AddWithValue, whose type inference can cause conversion errors and plan-cache pollution.
Null handling: a parameter whose Value is C# null is treated as not supplied; send DBNull.Value when you mean a database NULL.
Transactions: wrap batch operations in a local SqlTransaction for atomicity. Distributed transactions are supported on .NET Framework, and on .NET 7+ only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; they are not supported on Linux or macOS.
AcceptChanges: do not call AcceptChanges manually after DataAdapter.Update — Update calls it automatically on each row it saves.
Batch size tuning: start with a moderate UpdateBatchSize (for example 10–100) and measure; larger batches improve throughput only up to the point where lock escalation and memory pressure dominate.
37
Describe the ways ADO.NET can read, write, and work with XML data.
KnownRevise
ADO.NET integrates XML primarily through the DataSet and DataTable classes, which can serialize their contents and schema to XML and reconstruct them back without any custom mapping code. This makes XML a natural fit for persisting disconnected data, exchanging data between application tiers, or interoperating with systems that only speak XML.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET (System.Data)
Core XML Methods on DataSet and DataTable
WriteXml: writes the current data, and optionally the schema, to a file, Stream, TextWriter, or XmlWriter.
ReadXml: reads XML data into the DataSet, optionally inferring the schema or reading it from an embedded XSD.
WriteXmlSchema: writes only the XSD schema definition for the tables, columns, and relations.
ReadXmlSchema: reads only the schema, preparing the DataSet structure before the data payload arrives.
GetXml and GetXmlSchema: return the data and the schema as XML strings without writing to a file or stream.
Each method has overloads accepting a file path, Stream, TextReader/TextWriter, or XmlReader/XmlWriter, so you can work with files, memory streams, or network streams interchangeably. DataTable exposes the same WriteXml, ReadXml, WriteXmlSchema, and ReadXmlSchema methods when you only have a single table.
XmlWriteMode Options
Mode
What it produces
XmlWriteMode.WriteSchema
Writes the inline XSD schema plus the data; this is the default
XmlWriteMode.IgnoreSchema
Writes only the data rows, with no schema
XmlWriteMode.DiffGram
Writes a DiffGram containing original and current values, used to track changes across tiers
DiffGram is the mode that preserves both original and current row versions, which is what ADO.NET uses when you need to send changes back to a server and apply updates with concurrency awareness.
How Relations Map to XML
When a DataSet contains DataRelation objects, WriteXml nests child rows inside their parent rows as hierarchical elements. Column names become element or attribute names, and the generated XSD captures data types, nullability, and constraints. Reading that XML back with ReadXmlSchema followed by ReadXml reconstructs the tables and relations automatically.
Legacy XML Integration
XmlDataDocument: a .NET Framework-only class that kept a DataSet and an XML DOM synchronized. It is not available in .NET 8 and is not recommended for new work; use the DataSet XML methods or LINQ to XML instead.
DataSet binary serialization (RemotingFormat = SerializationFormat.Binary) relies on BinaryFormatter, which is obsolete and removed in .NET 9. Do not use it; prefer XML or JSON serialization.
Code Example
using System;
using System.Data;
// Build a DataSet with a parent-child relation
DataSet ds = new DataSet("Orders");
DataTable customers = ds.Tables.Add("Customers");
customers.Columns.Add("CustomerId", typeof(int));
customers.Columns.Add("Name", typeof(string));
DataTable orders = ds.Tables.Add("Orders");
orders.Columns.Add("OrderId", typeof(int));
orders.Columns.Add("CustomerId", typeof(int));
orders.Columns.Add("Amount", typeof(decimal));
ds.Relations.Add("CustomerOrders",
customers.Columns["CustomerId"],
orders.Columns["CustomerId"]);
customers.Rows.Add(1, "Acme Corp");
orders.Rows.Add(101, 1, 250.00m);
// Write data plus inline schema to a file
ds.WriteXml("orders.xml", XmlWriteMode.WriteSchema);
// Write only the schema so another tier can prepare its DataSet
ds.WriteXmlSchema("orders.xsd");
// Read the schema first, then the data (preserves types and relations)
DataSet loaded = new DataSet();
loaded.ReadXmlSchema("orders.xsd");
loaded.ReadXml("orders.xml");
Best Practices and Pitfalls
Read the schema before the data when the DataSet is empty; otherwise ReadXml infers the schema and the inferred column types may not match the original types.
Use XmlWriteMode.DiffGram when you need to transmit original and current values for concurrency-aware updates.
Validate XML against the XSD before ReadXml if the source is untrusted; ReadXml does not validate by default.
Prefer XmlReader/XmlWriter overloads for large payloads to avoid buffering the entire document in memory.
Do not rely on DataSet binary serialization; it uses BinaryFormatter, which is obsolete and removed in .NET 9.
38
How would you define an XML Schema Definition (XSD), and in what ways can ADO.NET work with one?
KnownRevise
An XML Schema Definition (XSD) is a W3C standard that describes the structure, element names, data types and constraints of an XML document. In ADO.NET, the DataSet and DataTable classes can read and write their schema as XSD, which lets you persist table layouts, relations and constraints, exchange schema between tiers, or generate strongly typed DataSet classes with the xsd.exe tool.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET (System.Data)
What XSD Describes for a DataSet
When ADO.NET serializes a DataSet to XSD, the schema captures:
Tables and columns: each DataTable becomes an <xs:element> with nested column elements typed as XML Schema data types (string, int, decimal, dateTime).
Primary keys and unique constraints: mapped to <xs:key> and <xs:unique> elements.
Relations: parent-child relationships between tables become <xs:keyref> elements, preserving referential links.
Nullability: nullable columns are represented so DBNull values round-trip correctly.
This means the XSD is a complete structural blueprint of the in-memory relational data, independent of the actual row data.
How to Read and Write XSD
ADO.NET provides dedicated methods on DataSet and DataTable:
WriteXmlSchema: writes only the schema (no data) to a file, TextWriter, XmlWriter or Stream.
ReadXmlSchema: reads an XSD and rebuilds the tables, columns and relations in an empty DataSet.
WriteXml with XmlWriteMode.WriteSchema: writes both data and schema together in one document.
GetXmlSchema: returns the schema as a string without writing to disk.
Method
Writes data?
Typical use
WriteXmlSchema
No
Sharing or versioning the structure alone
WriteXml(..., WriteSchema)
Yes
Exporting a full snapshot
ReadXmlSchema
No
Pre-building a DataSet before filling it
ReadXml
Yes
Loading data and schema together
Generating Strongly Typed DataSets
The xsd.exe tool (shipped with the .NET Framework SDK and Visual Studio / Windows SDK Build Tools; it is not part of the .NET 8 SDK) can compile an XSD into a C# class that derives from DataSet. This gives you compile-time access to tables and columns instead of string-based DataRow["Column"] lookups. Strongly typed DataSets are a legacy pattern; for new development prefer EF Core or Dapper, but XSD generation remains useful for interoperability with services that exchange DataSet XML.
Best Practices and Pitfalls
Validate before reading: an XSD that does not match the expected structure can create unexpected tables; validate incoming schemas in untrusted scenarios.
XmlWriteMode limits: XmlWriteMode supports only WriteSchema, IgnoreSchema and DiffGram - do not rely on other modes.
Avoid binary serialization: DataSet.RemotingFormat = Binary relies on BinaryFormatter, which is obsolete and removed in .NET 9; use XSD/XML for persistence instead.
Schema drift: when a producer changes column types or names, consumers reading the XSD may fail at runtime; version your schemas and test compatibility.
Data types are approximate: SQL types map to XML Schema types, so precision and scale information for decimals may be lost in the XSD representation.
39
What mechanisms does ADO.NET provide for loading and persisting XML data using DataSet and DataTable classes?
KnownRevise
ADO.NET supports XML data interchange primarily through the ReadXml and WriteXml methods available on DataSet and DataTable objects, enabling storage and retrieval of relational data as structured XML without manual serialization logic.
Read full answerHide full answer
Applies to.NET 8 with System.Data
Core XML Integration Methods
The DataSet and DataTable classes expose WriteXml to serialize data to XML and ReadXml to populate the object from XML. These methods handle the mapping between relational rows and XML elements automatically.
WriteXml: Outputs data to a file path, Stream, TextReader, or XmlWriter. On a DataSet, all contained tables are written; on a DataTable, only that table is written.
ReadXml: Loads XML from the same source types and returns an XmlReadMode value indicating how schema was interpreted.
ReadXmlSchema / WriteXmlSchema: Load or save only the XSD schema structure, without data, giving finer control over initialization.
Mapping behavior: By default, each row becomes an XML element and each column becomes a nested element; schema-aware writes can express data types, constraints, and relations.
XmlWriteMode Enumeration
WriteXml accepts an XmlWriteMode parameter to control whether schema is included.
IgnoreSchema: Writes only the data rows. Useful when the consumer already knows the schema.
WriteSchema: Includes an inline XSD schema definition along with the data, allowing a recipient to reconstruct table structure, types, and constraints.
DiffGram: Writes the current state alongside the original state, preserving change tracking information. This is useful for synchronization and disconnected update scenarios.
Reading XML and Schema
ReadXml uses XmlReadMode to determine how to handle schema information found in the XML.
Auto: Chooses behavior based on whether an inline schema is present.
ReadSchema: Reads an inline schema if present; otherwise may infer or fail depending on the source.
IgnoreSchema: Ignores inline schema and loads data into existing tables only; if no matching schema exists, no data is loaded.
Inferschema: Generates a schema from the XML structure when none exists.
DiffGram: Reads a DiffGram and preserves original/current row states.
InferTypedSchema: Infers schema and attempts to assign .NET types.
If the schema is missing and the target structure is not predefined, the result depends on the mode: InferSchema creates a schema from the data, IgnoreSchema generally loads nothing without matching columns, and Auto selects inference when needed. Using ReadXmlSchema separately allows loading only the structure before data arrives.
Best Practices and Limitations
Avoid DataSet.RemotingFormat = Binary because it relies on BinaryFormatter, which is obsolete and removed in .NET 9.
Always validate XML sources before calling ReadXml to prevent injection or schema poisoning attacks.
For large datasets, prefer XmlWriter and XmlReader overloads instead of file paths to manage memory efficiently.
Do not rely on XML serialization for security-sensitive data without encryption, because the output is plain text by default.
Use DiffGram only when change tracking across tiers is needed; otherwise WriteSchema or IgnoreSchema produces simpler, smaller documents.
40
What typical exceptions occur during ADO.NET database operations, and what are the recommended strategies for handling them properly?
KnownRevise
ADO.NET data access primarily raises SqlException for database-level errors and timeouts, alongside InvalidOperationException for invalid object states. Effective handling requires catching specific exception types, ensuring resource disposal through using statements, and implementing retry logic for transient failures rather than swallowing errors.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient
Common Exception Types
SqlException: The primary exception thrown by SQL Server, containing error Number, Severity, State, and an Errors collection with detailed error information. Common numbers include 1205 (deadlock victim), 2627 (unique constraint violation), 547 (foreign key conflict), and -2 (command timeout). It derives from DbException.
InvalidOperationException: Occurs when an operation is invalid for the current object state, such as opening an already open SqlConnection, executing a command on a closed connection, or reading from a closed or exhausted SqlDataReader.
ArgumentException / ArgumentNullException: Thrown when invalid arguments are passed, for example a null connection string, an empty parameter name, or a mismatched parameter type.
ObjectDisposedException: Raised when accessing a disposed SqlConnection, SqlCommand, or SqlDataReader.
Timeout Errors: Command timeouts manifest as SqlException with Number equal to -2; there is no separate TimeoutException in ADO.NET.
TransactionException: May be thrown when a SqlTransaction is rolled back or committed incorrectly, or when a distributed transaction cannot be promoted (platform-dependent).
Handling Strategies
Specific Catch Blocks: Catch SqlException before general Exception to access SQL-specific properties like Number and Errors. Catch InvalidOperationException separately for state issues.
Resource Management: Wrap SqlConnection, SqlCommand, SqlDataReader, and SqlTransaction in using statements (or await using for async) to guarantee disposal even when an exception occurs.
Transient Error Detection: Inspect SqlException.Number to identify retry-worthy errors (1205 deadlock, 1222 lock timeout, -2 command timeout) versus fatal errors (2627 constraint violation, syntax errors). Use a retry policy with exponential backoff for transient faults.
Logging and Security: Log exception details server-side (including Number, Message, and stack trace) but expose only generic messages to end users to prevent information disclosure. Never log connection strings or credentials.
Connection State Management: Check connection.State != ConnectionState.Open before calling Open() to avoid InvalidOperationException. Use ConnectionState to track state in long-running operations.
Transaction Rollback: In a catch block, roll back an active SqlTransaction if the connection is still open, then rethrow or handle the error.
Async Exception Handling: When using ExecuteReaderAsync, ExecuteNonQueryAsync, etc., ensure await is inside a try-catch block; otherwise exceptions may be captured in the task and observed later.
Best Practices and Pitfalls
Avoid Swallowing Exceptions: Empty catch blocks hide failures; always log or rethrow. If you catch and handle, document why.
Parameter Safety: Always use explicit SqlDbType definitions with Parameters.Add; AddWithValue can infer incorrect types (e.g., treating a string as NVarChar instead of VarChar) and hurt plan reuse.
Command Timeout: Set CommandTimeout explicitly for long-running queries; the default is 30 seconds. A timeout throws SqlException with number -2.
Distributed Transactions: On .NET 8, distributed transactions via TransactionScope are supported only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; they are not supported on Linux or macOS.
Null Values: Pass DBNull.Value for database NULLs; a C# null parameter value is treated as “parameter not supplied”.
Reader Usage: A SqlConnection can have only one open SqlDataReader at a time unless MultipleActiveResultSets=True is in the connection string.
Configuration: Store connection strings in configuration files or secret manager, never hardcode them in source code.
41
What is the recommended approach for capturing and logging database failures in an application that uses ADO.NET?
KnownRevise
ADO.NET does not include its own logging framework, so error logging is the application’s responsibility: you catch the provider-specific exception, extract useful diagnostic details, and write them through your application’s logging abstraction, typically Microsoft.Extensions.Logging.ILogger. For SQL Server, the exception is Microsoft.Data.SqlClient.SqlException, which exposes the server-side error number, severity class, state, procedure and line number.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient 5.x
How ADO.NET Surfaces Database Errors
Provider-specific exceptions: SQL Server failures throw SqlException; other providers throw their own types derived from the common System.Data.Common.DbException. Catch SqlException when you need SQL Server details, and DbException when you write provider-agnostic code.
SqlException.Errors: a SqlErrorCollection containing one SqlError per server message. A single statement can raise multiple errors, so iterate the collection when you need all messages.
Key properties: Number (server error number), Class (severity), State, LineNumber, Procedure, Server, ClientConnectionId, and Message.
Common error numbers: -2 indicates a command timeout, 1205 means the transaction was chosen as a deadlock victim, and 18456 is a login failure. These are useful for distinguishing transient failures from permanent configuration or permission problems.
What to Log
Structured details, not plain strings: log the error number, class, procedure, line number and the operation name so failures can be grouped and searched.
The exception object itself: pass the exception to ILogger.LogError so the stack trace and inner exceptions are captured by the logging provider.
Correlation identifiers: include an activity or request ID so a database failure can be traced across services.
Avoid sensitive data: do not log connection strings, passwords, tokens, full SQL with interpolated user input, or parameter values that may contain personal data. Log the command text only in controlled diagnostics, never user input inline.
Classify severity: use LogError for failures that break the operation, LogWarning for retried transient errors, and reserve LogCritical for unavailable databases or lost connectivity.
Implementing Logging Correctly
Wrap connections, commands, readers and transactions in using blocks so resources are released even when an exception is thrown.
Catch SqlException explicitly, log it, and rethrow unless you can genuinely recover. Do not swallow exceptions with an empty catch.
Use when filters to handle specific cases such as timeouts or deadlocks separately from general SQL errors.
Let transient-error handling and logging stay separate concerns: log first, then apply retry logic if the error is transient.
Prefer logging through ILogger rather than writing to files or the database from data-access code, so the application controls sinks, filtering and redaction.
Code Example
using System.Data;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
public sealed class CustomerRepository
{
private readonly string _connectionString;
private readonly ILogger<CustomerRepository> _logger;
public CustomerRepository(string connectionString, ILogger<CustomerRepository> logger)
{
_connectionString = connectionString;
_logger = logger;
}
public string? GetCustomerName(int customerId)
{
try
{
using var connection = new SqlConnection(_connectionString);
using var command = new SqlCommand(
"SELECT Name FROM dbo.Customers WHERE CustomerId = @id", connection);
// Explicit type; never use AddWithValue.
command.Parameters.Add("@id", SqlDbType.Int).Value = customerId;
connection.Open();
return (string?)command.ExecuteScalar();
}
catch (SqlException ex) when (ex.Number == -2)
{
_logger.LogError(ex,
"Command timeout while reading customer {CustomerId}", customerId);
throw;
}
catch (SqlException ex)
{
_logger.LogError(ex,
"SQL error {ErrorNumber} (class {ErrorClass}) in {Procedure} " +
"while reading customer {CustomerId}",
ex.Number, ex.Class, ex.Procedure, customerId);
throw;
}
}
}
Best Practices and Common Pitfalls
Do not log the full connection string: it may contain credentials or tokens.
Do not log user input or parameter values by default: identifiers such as CustomerId are usually safe; free-text values may not be.
Do not catch Exception and continue: catch SqlException or DbException and let unexpected exceptions propagate.
Remember multiple errors per command: for batch or multi-statement work, inspect ex.Errors rather than only ex.Message.
Do not confuse client timeouts with server failures: Number == -2 comes from the command timing out, not from the server rejecting the query.
Use parameterized queries always: correct error logging is not a substitute for preventing SQL injection in the first place.
42
How should connection strings used by ADO.NET be protected from accidental exposure or misuse?
KnownRevise
The recommended approach is to never hardcode connection strings in source code; instead store them in a protected configuration source such as environment variables, a secret manager, or a cloud key vault, and use Managed Identity or integrated security whenever the platform supports it so that no password appears in configuration at all.
Read full answerHide full answer
Applies to.NET 8 and C# 12 with Microsoft.Data.SqlClient
Keep Secrets Out of Source Code
Development: use the .NET Secret Manager tool (dotnet user-secrets), which stores values outside the project tree and is never committed to source control.
Production: read from environment variables, Azure Key Vault (via the Azure.Extensions.AspNetCore.Configuration.Secrets package), AWS Secrets Manager, or HashiCorp Vault through the configuration system.
Local files: appsettings.json should contain only non-sensitive placeholders; real credentials belong in appsettings.*.local.json (gitignored) or User Secrets.
Prefer Managed Identity and Integrated Security
Azure SQL: use Authentication=Active Directory Default or a managed identity, which removes the password from the string entirely and relies on Azure RBAC.
On-premises Windows: use Integrated Security=true (Windows Authentication), which leverages Kerberos and requires no stored credential.
Linux containers: managed identity or certificate-based authentication is preferred over password-based SQL login.
Encrypt Configuration When Necessary
.NET Framework (legacy): the ProtectedConfiguration API with RsaProtectedConfigurationProvider can encrypt the <connectionStrings> section via aspnet_regiis -pe; this legacy API is not available in .NET 8.
.NET 8: there is no built-in configuration encryption provider; rely on external secret stores and operating-system file ACLs instead.
DPAPI: DpapiProtectedConfigurationProvider works only on Windows and should not be assumed available on Linux or macOS.
Harden the Connection String Itself
Persist Security Info=false: set explicitly so the password cannot be read back from an open SqlConnection.ConnectionString.
Encrypt=True;Trust Server Certificate=false: enforces TLS and prevents man-in-the-middle attacks; validate the server certificate.
Least privilege: the database user in the string should have only the permissions the application actually needs, and credentials should be rotated on a schedule.
Code Example
using System;
using System.Data;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
public class DataAccess
{
private readonly string _connectionString;
public DataAccess(IConfiguration configuration)
{
// Loaded from env vars, Key Vault, or User Secrets - never hardcoded
_connectionString = configuration.GetConnectionString("DefaultConnection")
?? throw new InvalidOperationException("Connection string not configured.");
}
public async Task<int> GetProductCountAsync(int categoryId)
{
await using var connection = new SqlConnection(_connectionString);
await connection.OpenAsync();
await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Products WHERE CategoryId = @id";
// Explicit parameter type, no AddWithValue
command.Parameters.Add("@id", SqlDbType.Int).Value = categoryId;
return (int?)await command.ExecuteScalarAsync() ?? 0;
}
}
Common Pitfalls
Hardcoding: embedding a connection string literal in code guarantees it ends up in source control and build artifacts.
Logging: never write the connection string to logs, telemetry, or exception messages; the password may be included.
Cross-platform assumptions: do not rely on DPAPI or aspnet_regiis encryption when the app runs on Linux containers.
Over-permissioned accounts: a connection string tied to a sa-level login turns any leak into a full database compromise.
43
Which best practices should developers follow when using ADO.NET to build high-performance applications?
KnownRevise
The core ADO.NET best practices for high-performance work focus on keeping connections open for the shortest time possible, streaming rows instead of buffering entire result sets, and preventing plan-cache pollution from implicit type conversions. Applying these patterns keeps throughput high and latency predictable under load.
Read full answerHide full answer
Applies to.NET 8 and C# 12 with Microsoft.Data.SqlClient
Connection Management
Connection pooling: enabled by default in Microsoft.Data.SqlClient; reuse the exact same connection string so requests share a pool instead of creating new physical connections — any difference between two strings, even extra whitespace, creates a separate pool.
Open late, close early: wrap SqlConnection, SqlCommand and SqlDataReader in using (or await using) so they dispose promptly and the connection returns to the pool immediately.
Async I/O: use OpenAsync, ExecuteReaderAsync and ReadAsync so threads are freed to serve other requests while waiting on the database, which matters most in ASP.NET Core on both Windows and Linux.
Avoid long-lived connections: never hold a connection open across user think time or between HTTP requests; take one from the pool, do the work, and return it.
Commands and Parameters
Never use AddWithValue: it infers the SQL type from the CLR value, which causes implicit conversions, prevents index use, and bloats the plan cache with duplicate plans.
Approach
Type binding
Consequence
command.Parameters.Add("@id", SqlDbType.Int).Value = id
Explicit and stable
Small plan cache, indexes stay usable
command.Parameters.AddWithValue("@id", id)
Inferred from the CLR value
Implicit conversions and plan-cache bloat
Set lengths explicitly: for variable-length types set Size (and Precision/Scale for decimals) so the engine does not treat every value as max length.
Send NULLs correctly: assign DBNull.Value for a database NULL; a C# nullValue means the parameter is treated as not supplied.
Prepare selectively: command.Prepare() can help a command executed many times, though with sp_executesql-based plan reuse in .NET 8 the benefit is often small; measure before adopting it widely.
Reading and Materialization
Prefer SqlDataReader: it streams rows forward-only with minimal allocation; avoid DataSet/DataAdapter when you read data once and do not need disconnected change tracking.
LINQ over a streaming reader: DbDataReader implements IEnumerable, so reader.Cast<IDataRecord>() lets you apply LINQ while rows are still being fetched instead of buffering them first.
Handle NULLs safely: DataRow.Field<int>() and reader.GetInt32 throw on DBNull; use Field<int?>() or check reader.IsDBNull for nullable columns.
Select only needed columns: avoid SELECT * to reduce network traffic, memory pressure and unnecessary serialization.
Bulk Operations
Use SqlBulkCopy for large inserts: it is far faster than row-by-row INSERT. By default it does not check constraints or fire triggers; pass SqlBulkCopyOptions.CheckConstraints or FireTriggers when you need them.
All-or-nothing batches: SqlBulkCopyOptions.UseInternalTransaction commits each batch separately, so wrap the copy in your own SqlTransaction when the whole load must succeed or fail together.
Common Pitfalls
AddWithValue: the single most common performance mistake; always declare SqlDbType explicitly.
Sync-over-async: calling .Result or .Wait() on async ADO.NET methods can deadlock under load; use await end to end.
Undisposed readers: an open reader blocks its connection from returning to the pool; await using prevents this.
DataSet binary serialization: DataSet.RemotingFormat = Binary relies on BinaryFormatter, which is obsolete and removed in .NET 9; do not rely on it.
Connection string drift: keep connection strings in one configuration source so minor edits do not silently create extra pools.
44
Why should SqlConnection and SqlCommand objects be wrapped in a using statement, and how does that pattern work in practice?
KnownRevise
Wrapping SqlConnection and SqlCommand in a using statement guarantees that their unmanaged resources are released deterministically, even if an exception is thrown during data access. Both types implement IDisposable, so using calls Dispose at the end of the block, which closes the connection and returns it to the pool instead of leaving it open until the garbage collector runs.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient
Why It Matters
Unmanaged resources: Under the hood, a SqlConnection holds a native handle to a SQL Server session. The garbage collector reclaims managed memory, but it cannot promptly release these native handles.
Connection pool exhaustion: If connections are not closed, the pool fills up and new Open calls block or fail with a timeout, which is a common cause of production outages under load.
Exception safety: Without using, an exception between Open and Close leaves the connection open. using translates to a try/finally where Dispose runs in the finally block.
How It Works
When the compiler sees using (var conn = new SqlConnection(...)), it generates a try block around your code and a finally block that calls conn.Dispose().
SqlConnection.Dispose closes the connection if it is still open and returns it to the connection pool (when pooling is enabled, which is the default).
SqlCommand.Dispose releases any associated resources, including reader references and prepared statement handles.
In C# 8 and later, including C# 12, you can use a using declaration (using var conn = ...;) which disposes the object at the end of the enclosing scope, reducing nesting.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = "Server=localhost;Database=AppDb;Trusted_Connection=True;TrustServerCertificate=True;";
// Classic using block style
using (var connection = new SqlConnection(connectionString))
{
connection.Open(); // Open before executing any command
using (var command = new SqlCommand("SELECT Name FROM Users WHERE Id = @id", connection))
{
command.Parameters.Add("@id", SqlDbType.Int).Value = 42;
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
Console.WriteLine(reader.GetString(0));
}
} // reader.Dispose closes the reader
} // command.Dispose
} // connection.Dispose returns it to the pool
// Equivalent C# 12 using-declaration style, less nesting
using var connection2 = new SqlConnection(connectionString);
connection2.Open();
using var command2 = new SqlCommand("SELECT COUNT(*) FROM Users", connection2);
int count = (int)command2.ExecuteScalar();
// Both dispose automatically at the end of the enclosing scope
Connection Pooling and Dispose
Dispose does not necessarily close the physical TCP connection; with pooling enabled it returns the session to the pool for reuse by a later Open with the same connection string.
This is why using is both safe and performant: you get prompt resource release without the cost of repeatedly establishing new physical connections.
If you need to disable pooling for a specific scenario, add Pooling=false to the connection string, but this is rarely advisable for web applications.
Best Practices and Common Pitfalls
Open as late as possible: Call Open immediately before the command, not at the start of a method, so the connection spends minimal time checked out of the pool.
Dispose readers too: A SqlDataReader is also IDisposable. Leaving it open keeps the connection busy and can block subsequent commands on the same connection.
One active reader per connection: Unless you enable MultipleActiveResultSets=true in the connection string, a second command on an open connection with a pending reader will fail.
Never rely on finalizers: The finalizer may eventually close the connection, but timing is non-deterministic and finalization can be delayed indefinitely under memory pressure.
Transactions need care: If you create a SqlTransaction, dispose it in its own using as well, and remember that an uncommitted transaction is rolled back when the connection closes or the transaction is disposed.
Async code: using works identically with await; the disposal happens after the awaited task completes within the block.
45
Which performance pitfalls should you watch for when building data access code with ADO.NET, and how do you avoid them?
KnownRevise
The most impactful ADO.NET performance considerations are connection pooling, parameterized commands, streaming results with a DbDataReader instead of loading everything into memory, and using bulk-copy APIs for large writes. Most ADO.NET slowness in production comes not from the framework but from misusing these features.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (SQL Server); the general principles apply to other managed providers as well.
Connection Pooling
SqlConnection is pooled by default, so “opening” a connection is usually just leasing one from the pool and closing it returns it. This only works when you follow the pooling rules:
Exact connection-string match: pools are keyed on the connection string, so any variation (a different ApplicationName, a changed Max Pool Size, different credentials) creates a separate pool.
Always close/return connections: wrap every connection in a using block so Dispose returns it to the pool even on exceptions. Leaving connections open exhausts the pool.
Pool size: Max Pool Size defaults to 100. Under heavy load, raising it can help, but a higher value also increases SQL Server connection pressure; often the real fix is shorter-lived connections.
Connection Reset (default true) makes the pool run sp_reset_connection when a connection is reused, which clears temporary state. Keep it enabled unless you have measured the overhead.
Connection Lifetime / Load Balance Timeout: connections older than this are destroyed instead of reused, which helps with load-balanced clusters.
Commands and Parameters
Parameterize every query: parameters are not only a security requirement, they let SQL Server cache and reuse the execution plan. String-concatenated SQL produces a new plan per distinct literal, which wastes CPU and memory.
Avoid AddWithValue: it infers the SQL type from the .NET value, which produces NVARCHAR for strings with no regard for the actual column size and can silently truncate or widen parameters, defeating plan reuse. Use Parameters.Add(name, SqlDbType) and set Size, Precision and Scale explicitly.
CommandTimeout defaults to 30 seconds. Long-running reports or bulk loads need a higher value, but a timeout is usually a symptom of a missing index or a bad query — fix the query first.
CommandType.StoredProcedure avoids the extra parsing/permission steps of ad-hoc text and gives you a stable plan.
Reading Data Efficiently
Prefer SqlDataReader over DataSet/DataTable for read-only results: it streams rows one at a time instead of buffering the whole result set in memory.
CommandBehavior.CloseConnection ensures the connection is closed when the reader is disposed, which is the safe pattern for returning a reader from a data-access method.
MARS (MultipleActiveResultSets=true) lets you run several readers on one connection, but it adds server-side overhead and can hide serialization problems. Enable it only when you genuinely need interleaved reads.
DbDataReader implements IEnumerable, so reader.Cast<IDataRecord>() lets you project rows with LINQ while still streaming — do not call ToList() on it if the caller can consume rows lazily.
For DataTable, Load(reader) is fine, but remember DataRow.Field<int>() throws on DBNull; use the nullable overload Field<int?>() for nullable columns.
Bulk Inserts
SqlBulkCopy is orders of magnitude faster than individual INSERT statements for large loads, with important defaults to know: it does not check constraints and does not fire triggers unless you pass SqlBulkCopyOptions.CheckConstraints / FireTriggers. Set BatchSize to avoid loading the whole source at once. UseInternalTransaction commits each batch separately, so for all-or-nothing semantics enlist the bulk copy in an explicit SqlTransaction instead.
Code Example
using System.Collections.Generic;
using System.Data;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
// Streams orders for one customer without buffering the whole result set.
async IAsyncEnumerable<(int OrderId, int CustomerId, DateTime? OrderDate)> GetOrdersAsync(
int customerId, string connectionString)
{
// Connection strings belong in configuration, never in code.
// Leases a pooled connection; Dispose returns it, even on exceptions.
using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
using var command = new SqlCommand(
"SELECT OrderId, CustomerId, OrderDate FROM Sales.Orders WHERE CustomerId = @customerId",
connection);
command.CommandType = CommandType.Text;
command.CommandTimeout = 60; // seconds; default is 30
// Explicit type: avoids AddWithValue type inference and plan-reuse problems.
command.Parameters.Add("@customerId", SqlDbType.Int).Value = customerId;
// CloseConnection disposes the underlying connection with the reader.
using var reader = await command.ExecuteReaderAsync(CommandBehavior.CloseConnection);
while (await reader.ReadAsync())
{
// A nullable column reads as DBNull; convert it to a nullable value.
DateTime? orderDate = reader.IsDBNull(2) ? null : reader.GetDateTime(2);
yield return (reader.GetInt32(0), reader.GetInt32(1), orderDate);
}
}
Best Practices and Common Pitfalls
Use the async methods (OpenAsync, ExecuteReaderAsync, ReadAsync) and never block on them with .Result or .Wait(), especially in web applications.
Do not share one open connection across threads; open, use, and dispose per unit of work and let pooling handle the cost.
Do not wrap every call in a transaction “for safety”: unnecessary transactions hold locks longer and reduce throughput.
Fetch only what you need: SELECT explicit columns with a WHERE clause rather than SELECT *; network round trips and serialization dominate many workloads.
Measure before tuning: check pool counters, SQL Server wait statistics, and execution plans before changing pool sizes or timeouts.
46
How do a DataSet and a DataTable differ from each other in ADO.NET?
KnownRevise
A DataSet is an in-memory cache that can hold multiple tables, the relationships between them, and constraints, whereas a DataTable represents a single table of rows and columns. In other words, a DataSet acts like a small disconnected database, and a DataTable is one table inside it (or standing alone). Both belong to ADO.NET’s disconnected data architecture and work with a DataAdapter to fill and update data.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET
Core Difference
DataTable: A single in-memory table made of DataRow objects and DataColumn objects. It can exist independently or as a member of a DataSet.
DataSet: A container holding a collection of DataTable objects (Tables), plus DataRelation objects that define parent-child links between tables, and Constraint objects that enforce rules such as primary keys and foreign keys.
Structure Comparison
Aspect
DataTable
DataSet
Number of tables
One
Multiple, via the Tables collection
Relationships
None
DataRelation objects linking tables
Constraints
Limited when standalone
Full set across all contained tables
Filtering and sorting
DataView
DataViewManager across tables
Memory overhead
Lightweight
Heavier due to container metadata
XML serialization
Serializes one table
Serializes tables, schema, and relations
When to Use Each
Use a DataTable when your operation involves a single result set, for example binding the result of one query to a grid, iterating rows for reporting, or passing tabular data between layers.
Use a DataSet when you need several related tables together, such as master-detail data like orders and order details, or when you must track and submit changes across multiple tables as a unit.
How They Work with a DataAdapter
A DataAdapter.Fill can populate either a DataTable directly or a DataSet; you do not need a DataSet just to read one table.
Both keep a copy of data in memory after the connection closes, and changes are tracked per row (DataRowState).
DataAdapter.Update sends inserted, modified, and deleted rows back to the database and calls AcceptChanges on each row it saves, so you should not call AcceptChanges again afterward.
Practical Guidance and Pitfalls
Do not wrap a single table in a DataSet; the extra container overhead buys you nothing.
When reading values, DataRow.Field<int>() throws if the column contains a database NULL; use Field<int?>() for nullable columns and check HasValue.
For a parameter whose C# value is null, send DBNull.Value; a C# null parameter value is treated as not supplied.
In new .NET 8 development, many data-access scenarios have moved to entity classes with EF Core or Dapper, but DataSet and DataTable remain appropriate for disconnected scenarios, reporting, and integration with legacy components.
Summary
Choose a DataTable for one table and a DataSet when you genuinely need multiple related tables, relationships, or cross-table constraints in memory.
47
What is the process for creating and appending an additional DataColumn to an existing DataTable?
KnownRevise
You add a column by constructing a DataColumn (or using one of the DataTable.Columns.Add overloads) and appending it to the table’s DataColumnCollection, which is exposed through the DataTable.Columns property. A DataColumn defines the schema of one field in an in-memory ADO.NET DataTable — its name, .NET type, and constraints such as whether it allows nulls.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and `System.Data` (ADO.NET `DataTable`); the same API exists in .NET Framework 4.8 and all .NET Core/.NET 5+ versions.
How It Works
DataTable.Columns returns a DataColumnCollection that holds the table’s column definitions in a defined order.
Columns.Add(DataColumn) appends a fully configured DataColumn instance to that collection.
Columns.Add(string name, Type type) is a shortcut that creates the column and sets its ColumnName and DataType in one call.
Columns.Add(string name) creates a column of type string when you do not specify a type.
A column’s DataType can be set any time before the column is added to a table; once the column belongs to the table, assigning DataType throws an ArgumentException.
Adding a column whose ColumnName duplicates an existing (case-insensitive by default) name throws a DuplicateNameException.
Key Behaviors and Rules
Column identity: ColumnName is the logical name; Caption is a display label (defaults to the column name). Lookups in Columns["name"] and in DataRow["name"] use ColumnName.
Nullability: AllowDBNull defaults to true. Set it to false before adding rows if the field is required.
Primary keys: to make the column part of the key, assign an array to DataTable.PrimaryKey after adding the column; a key column must have AllowDBNull = false and be unique.
Auto-increment: set AutoIncrement = true, plus AutoIncrementSeed and AutoIncrementStep, for surrogate key columns. Such columns are typically marked ReadOnly = true.
Computed values: set Expression to derive the column from other columns (for example "UnitPrice * Quantity"). The referenced columns must already exist, and the DataType must be compatible with the expression result.
Existing rows: when a column is added to a table that already contains rows, every existing row gets a default value for the new column — the DefaultValue you set, otherwise DBNull.Value for nullable columns, which throws on non-nullable value-type columns without a default.
Code Example
using System.Data;
DataTable CreateOrderTable()
{
var table = new DataTable("Order");
// Explicitly configured column, then appended.
var id = new DataColumn("OrderId", typeof(int))
{
AllowDBNull = false,
AutoIncrement = true,
AutoIncrementSeed = 1,
AutoIncrementStep = 1,
ReadOnly = true,
Caption = "Order ID"
};
table.Columns.Add(id);
// Shortcut overloads: name + type in one call.
table.Columns.Add("CustomerName", typeof(string));
table.Columns.Add("OrderDate", typeof(DateTime));
table.Columns["CustomerName"]!.AllowDBNull = false;
// Expression columns need the referenced columns to exist first.
table.Columns.Add("UnitPrice", typeof(decimal));
table.Columns.Add("Quantity", typeof(int));
table.Columns.Add("LineTotal", typeof(decimal));
table.Columns["LineTotal"]!.Expression = "UnitPrice * Quantity";
// Primary key requires the column to be non-null and unique.
table.PrimaryKey = new[] { table.Columns["OrderId"]! };
return table;
}
void AddColumnToExistingTable(DataTable table)
{
// Adding a column after rows exist fills existing rows with DefaultValue.
if (!table.Columns.Contains("Notes"))
{
table.Columns.Add("Notes", typeof(string));
}
}
Best Practices and Common Pitfalls
Set schema before adding data: add all columns, constraints and the primary key before inserting rows, so validation applies from the first row onward.
Prefer strongly typed access: DataRow.Field<T>("CustomerName") avoids casting, but use Field<T?> for nullable columns because Field<T> throws on DBNull.
Do not rename or retype columns after data is loaded: retyping is not allowed once the column belongs to the table, and renaming can break code and bindings that reference the old name.
Case-insensitive matching: Columns["orderid"] matches a column named OrderId by default, which is convenient but can hide typos.
Use Contains before optional additions: call table.Columns.Contains("Notes") before adding a column that another routine may already have added, to avoid a DuplicateNameException.
Remember DBNull.Value: reading a null cell returns DBNull, not C# null; compare with row.IsNull("Notes") or Convert.IsDBNull before casting.
48
Explain what a DataView is used for in ADO.NET.
KnownRevise
A DataView is a lightweight, bindable view over a single DataTable that lets you sort, filter, and search the underlying rows without modifying or copying the table itself. It is the standard way to present the same in-memory data in different orders or subsets, especially in data-binding scenarios, while the source DataTable retains its canonical row state.
Read full answerHide full answer
Applies toADO.NET with .NET 8 and C# 12, using `Microsoft.Data.SqlClient`; the `DataView` type itself is part of `System.Data` and behaves the same in .NET Framework and .NET Core/.NET 5+.
How It Works
A view, not a copy: A DataView references one DataTable (exposed through DataView.Table) and maintains a set of row references plus index structures. Creating a view does not duplicate the rows, so changes to the underlying rows are immediately visible through the view.
Row state filtering: DataView.RowStateFilter restricts which versions of rows appear, using DataViewRowState values such as CurrentRows (the default: unchanged, added, and modified rows), ModifiedOriginal, ModifiedCurrent, Deleted, and OriginalRows. This makes it useful for inspecting pending edits before a DataAdapter.Update.
Sorting: DataView.Sort takes a SQL-like expression such as "LastName ASC, HireDate DESC". The view builds an index so enumeration and lookups follow that order.
Filtering: DataView.RowFilter accepts a boolean expression evaluated per row, for example "Age >= 18 AND Department = 'Sales'". For one-off finds without maintaining a filter, DataView.Find and FindRows use the current sort index and are faster than re-scanning.
Editing: With DataView.AllowEdit, AllowNew, and AllowDelete set to true (defaults), you can edit, add, or delete rows through the view. Those changes are written back to the underlying DataTable with the appropriate DataRowState, so a later DataAdapter.Update persists them.
Key Features
Multiple views of one table: You can create several DataView objects over the same DataTable, each with its own sort and filter, while the table is stored only once.
Data binding: DataView implements IBindingList, ITypedList, and IList, so it binds directly to Windows Forms grids (DataGridView.DataSource) and other bindable UIs; DataTable.DefaultView is the view that controls typically bind to by default.
Custom sorting and filtering logic: DataView exposes ApplyDefaultSort (uses the table’s primary key order when no Sort is set) and, for scenarios where expressions are not enough, you can derive from DataView or use LINQ to DataSet (table.AsDataView() after a LINQ query) to produce a view from a projection.
Index maintenance: The view keeps its index up to date as rows change, so sorting and Find stay consistent at the cost of some overhead on every table modification.
DataView vs. Other ADO.NET Data Structures
Feature
DataTable
DataView
DbDataReader / SqlDataReader
Storage
Stores rows and their state
No row storage; references one table
Forward-only server cursor
Sorting
Only via Select, no live order
Live Sort with maintained index
Done in SQL (ORDER BY)
Filtering
Select returns a snapshot copy
Live RowFilter and RowStateFilter
Done in SQL (WHERE)
Editing
Full edit support
Optional edit/add/delete through the view
Read-only
Best use
Canonical in-memory data
Sorted/filtered views for binding and inspection
Streaming large results
Advantages and Limitations
Advantages: No data duplication; immediate reflection of table changes; simple, expression-based sorting and filtering; first-class data-binding support; lets you inspect pending inserts, updates, and deletes through RowStateFilter before saving.
Limitations: It is limited to one DataTable, so it cannot join related tables (use a DataRelation plus DataRow.GetChildRows or a LINQ join over DataSet instead). Expression syntax is restricted to the documented DataColumn.Expression operators and functions. Heavy use of RowFilter on large tables costs CPU and memory because indexes are rebuilt. It is an in-memory, disconnected construct: for large or frequently re-queried data, filtering and sorting in SQL Server is usually more efficient.
Best Practices
Set RowFilter and Sort once where possible rather than changing them in tight loops, since each change can rebuild the index.
Prefer DataView.FindRows over iterating the view when you need rows matching a key that is already covered by the Sort expression.
Use RowStateFilter to show users only their pending changes, and remember that deleted rows are excluded from the default CurrentRows view.
Do not call AcceptChanges on rows before a DataAdapter.Update: that would erase the inserted/modified/deleted state the adapter needs. DataAdapter.Update calls AcceptChanges on each row it saves.
For read-only display, set AllowEdit, AllowNew, and AllowDelete to false to prevent unintended edits flowing back to the table.
When a filter expression includes user input, validate and sanitize it; expression strings are concatenated, not parameterized, so values must be escaped properly.
49
Can you describe the types of constraints available in a DataSet and explain how they enforce data integrity?
KnownRevise
In ADO.NET, a DataSet constraint is an in-memory rule attached to DataTable columns that preserves integrity while data is disconnected. The framework defines two concrete constraint types—UniqueConstraint and ForeignKeyConstraint—and their validation is controlled by the EnforceConstraints flag on the DataSet.
Read full answerHide full answer
Applies to.NET 8 (ADO.NET / System.Data)
Constraint Types
UniqueConstraint: Requires values in one or more columns to be distinct. Assigning DataTable.PrimaryKey creates one automatically; you can also add a non-primary unique constraint on alternate keys.
ForeignKeyConstraint: Enforces referential integrity between a parent column set and a child column set. It exposes DeleteRule, UpdateRule, and AcceptRejectRule, each taking a Rule value such as Rule.Cascade, Rule.SetNull, Rule.SetDefault, or Rule.None.
ConstraintCollection: Constraints live in DataTable.Constraints, not in the DataSet directly. A DataRelation created with createConstraints = true also adds a ForeignKeyConstraint to the child table.
Constraint
Scope
Null behavior
Created automatically by
UniqueConstraint
One table, one or more columns
Multiple DBNull values allowed unless it is the primary key
DataTable.PrimaryKey
ForeignKeyConstraint
Parent and child tables
Child foreign-key columns may be null unless otherwise constrained
DataRelation(..., createConstraints: true)
How Enforcement Works
DataSet.EnforceConstraints is true by default. When enabled, a change that breaks a rule throws ConstraintException when the row edit ends—for example, at EndEdit, AcceptChanges, or when the row is added to the table.
Setting EnforceConstraints = false suspends checking, which is useful for bulk loading temporarily inconsistent rows. Restoring true validates every existing row and throws if any violation remains.
Validation occurs at row-commit time, not on every individual property assignment.
Only DataSet exposes EnforceConstraints; DataTable does not have this property.
Retrieving Constraints from a Database
A plain DataAdapter.Fill populates rows only; it does not create constraints.
DataAdapter.FillSchema, or setting MissingSchemaAction.AddWithKey before Fill, can recover primary-key metadata from SQL Server.
Foreign-key metadata is not retrieved automatically. To enforce referential integrity in memory, create ForeignKeyConstraint objects manually or add a DataRelation with constraints enabled.
These in-memory constraints protect the disconnected cache only; they do not alter or replace server-side constraints.
Best Practices and Pitfalls
Bulk loads: Turn EnforceConstraints off before inserting many rows, then turn it back on once; otherwise each row pays validation cost.
Nulls: A UniqueConstraint permits multiple DBNull values unless the constrained column is also the primary key, because primary-key columns cannot be null.
Server truth: In-memory constraints are a convenience, not a security boundary. Always define matching PRIMARY KEY, UNIQUE, and FOREIGN KEY constraints in SQL Server.
Serialization: Do not use DataSet.RemotingFormat = Binary; it depends on BinaryFormatter, which is obsolete and removed in .NET 9.
Nullable reads: When reading constrained columns that allow nulls, use DataRow.Field<int?>() rather than Field<int>(), which throws on DBNull.
50
Which .NET types are valid for use as column types in a DataTable?
KnownRevise
A DataTable stores its data in DataColumn objects, and each column’s DataType property fixes the .NET type of the values that column can hold. DataType is not open-ended, though: it accepts a documented set of base CLR types plus custom types that implement IList, and a DataRow rejects any value that is not assignable to its column’s DataType.
Read full answerHide full answer
Applies to.NET 8 / ADO.NET (`System.Data`); the same set of supported types applies to .NET Framework and earlier .NET releases.
Supported .NET Types
Primitive and numeric types: Boolean, Byte, SByte, Char, Decimal, Double, Single, Int16, Int32, Int64, UInt16, UInt32, UInt64 and String.
Date and time types: DateTime for dates and timestamps, TimeSpan for time-of-day and interval values.
Binary and identity types: Byte[] for BLOB or varbinary data, and Guid for uniqueidentifier values.
Metadata and enumeration types: Type, so a cell can hold a System.Type value, and Enum types.
Custom types: the documented rule is that a user-defined type must implement IList to be usable as a DataType; types outside the supported set throw an ArgumentException when the column is created.
How Type Enforcement Works
Setting DataColumn.DataType, or passing a Type to DataTable.Columns.Add, records the column’s type.
Assigning a value through DataRow[column] checks that the value is assignable to that column’s DataType; a mismatch throws ArgumentException.
A database NULL is represented by DBNull.Value, never C# null. DataRow.Field<int>() throws on DBNull, so use Field<int?>() when the column may be null.
Computed columns defined through DataColumn.Expression must produce results that are assignable to the column’s declared DataType.
Typical SQL Server to .NET Type Mapping
When a DataTable is filled from SQL Server with Microsoft.Data.SqlClient, the provider picks the column types:
SQL Server type
Typical DataColumn.DataType
BIGINT
Int64
INT
Int32
BIT
Boolean
DECIMAL, NUMERIC
Decimal
VARCHAR, NVARCHAR
String
DATETIME2, DATE
DateTime
TIME
TimeSpan
VARBINARY(MAX)
Byte[]
UNIQUEIDENTIFIER
Guid
XML
String (use SqlDataReader.GetSqlXml() when you need an XmlReader)
Common Pitfalls
DBNull handling: never compare a cell to null to detect a database null; use DBNull.Value, DataRow.IsNull(column), or the nullable Field<T?> accessor.
Type mismatch: assigning "123" (a string) to an Int32 column throws; convert the value to the column’s declared type first.
Nullable value types: Nullable<T> such as int? is not supported as a DataColumn.DataType; declare the column with the underlying value type and store DBNull.Value for missing values.
Binary serialization: DataSet.RemotingFormat = Binary relies on BinaryFormatter, which is obsolete and removed in .NET 9; avoid it.
Best Practices
Match each column’s DataType to the source database type, for example Decimal for SQL DECIMAL and DateTime for DATETIME2, so values round-trip without lossy conversions.
Use typeof(byte[]) for VARBINARY columns instead of storing hex strings.
Prefer the strongly typed Field<T> and SetField<T> accessors over the object indexer, so type errors surface at compile time.
Keep columns to the common CLR types unless a custom IList type genuinely solves a specific domain problem, since unusual types are harder to map, serialize and query.
51
What is the procedure for establishing a parent-child association between DataTable objects contained within a DataSet?
KnownRevise
You define a relationship between tables in a disconnected DataSet by instantiating a DataRelation object that maps a primary key column in a parent table to a corresponding foreign key column in a child table, then adding it to the DataSet’s Relations collection.
Read full answerHide full answer
Applies to.NET 8 (System.Data)
The DataRelation Class
The DataRelation class represents a link between two DataTable objects based on matching columns. It serves two main purposes: - Data Integrity: It can enforce referential integrity by automatically creating ForeignKeyConstraint and UniqueConstraint objects. - Navigation: It enables hierarchical traversal of data, allowing you to retrieve child rows from a parent row or vice versa without writing SQL joins.
Steps to Define a Relationship
Create Tables: Instantiate DataTable objects and add them to the DataSet.
Define Columns: Ensure the parent column (usually primary key) and child column (foreign key) have compatible data types (e.g., both typeof(int)).
Instantiate Relation: Create a DataRelation specifying a name, the parent DataColumn, and the child DataColumn.
Add to DataSet: Call DataSet.Relations.Add() to register the relationship.
Navigating Related Data
Once defined, you can traverse the relationship using DataRow methods: - GetChildRows: Called on a parent row to retrieve an array of matching child rows. - GetParentRow: Called on a child row to retrieve the single matching parent row. - Relation Name Overload: Use GetChildRows("RelationName") if multiple relations exist between the same tables.
Constraints and Enforcement
By default, constructing a DataRelation adds a ForeignKeyConstraint to the child table and a UniqueConstraint to the parent table. You can control this behavior using the constructor overload that accepts a bool createConstraints parameter. If set to false, you must manually manage integrity. Deleting a parent row with existing children will throw a ConstraintException unless the DeleteRule is set to Rule.Cascade or Rule.SetNull.
Code Example
using System;
using System.Data;
DataSet ds = new DataSet();
DataTable customers = new DataTable("Customers");
DataTable orders = new DataTable("Orders");
// Define columns with matching types for the relationship
customers.Columns.Add("CustomerID", typeof(int));
customers.Columns.Add("Name", typeof(string));
orders.Columns.Add("OrderID", typeof(int));
orders.Columns.Add("CustomerID", typeof(int));
ds.Tables.Add(customers);
ds.Tables.Add(orders);
customers.Rows.Add(1, "Alice");
orders.Rows.Add(101, 1);
// Create the relation on matching columns
DataRelation rel = new DataRelation("CustOrders",
customers.Columns["CustomerID"],
orders.Columns["CustomerID"]);
// Add the relation to the DataSet
ds.Relations.Add(rel);
// Navigate from parent to child
DataRow customerRow = customers.Rows[0];
DataRow[] childRows = customerRow.GetChildRows(rel);
Best Practices and Pitfalls
Match Data Types Exactly: The parent and child columns must have identical data types; otherwise, an exception is thrown at runtime.
Handle DBNull: When accessing values via DataRow.Field<T>(), remember it throws on DBNull. Use Field<int?>() for nullable foreign keys.
Avoid Binary Serialization: Do not set DataSet.RemotingFormat = Binary; it relies on BinaryFormatter, which is obsolete and removed in .NET 9.
Constraint Management: Explicitly configure DeleteRule and UpdateRule (e.g., Rule.Cascade) if you want automatic propagation of changes rather than exceptions.
Memory Considerations:DataSet keeps data in memory; use relationships for moderate-sized data, not for large-scale reporting where SQL joins are more efficient.
52
Explain what a ForeignKeyConstraint does in ADO.NET and show how to create and apply one between two DataTable objects.
KnownRevise
A ForeignKeyConstraint is an in-memory referential-integrity rule that ADO.NET attaches to a child DataTable, ensuring that every child row has a matching parent row in a related DataTable. It is the client-side counterpart of a database foreign key and is enforced by the DataSet while data is cached in memory, before changes are ever sent back to the server.
Read full answerHide full answer
Applies to.NET 8 with C# 12; `ForeignKeyConstraint`, `DataRelation`, and `Rule` live in `System.Data` and behave identically on .NET Framework 4.8.
How It Works
A ForeignKeyConstraint is typically associated with a DataRelation between two DataTable objects in the same DataSet, but it can also be created independently and added directly to the child table’s Constraints collection:
The child columns (Columns) are the foreign-key columns on the child table.
The related columns (RelatedColumns) are the primary- or unique-key columns on the parent table (RelatedTable).
The constraint lives in the child table’s Constraints collection and is only enforced while DataSet.EnforceConstraints is true, which is the default.
When you add a DataRelation to DataSet.Relations, the DataRelation constructor creates and installs the ForeignKeyConstraint automatically unless you pass createConstraint: false. The relation provides navigation between tables; the constraint provides enforcement. They are distinct objects serving distinct purposes.
Key Properties and Rules
ConstraintName: name used to look the constraint up in childTable.Constraints.
DeleteRule / UpdateRule: members of the Rule enum that decide what happens to child rows when a parent row is deleted or its key is changed.
AcceptRejectRule: AcceptRejectRule.Cascade or AcceptRejectRule.None; cascades AcceptChanges/RejectChanges calls from the parent to the child rows.
Rule value
Parent row deleted
Parent key updated
Cascade (default)
Child rows are deleted
Child foreign key is updated
None
ConstraintException is thrown
ConstraintException is thrown
SetNull
Child foreign key set to DBNull.Value
Child foreign key set to DBNull.Value
SetDefault
Child foreign key set to its DataColumn.DefaultValue
Child foreign key set to its DataColumn.DefaultValue
SetNull and SetDefault require the child foreign-key column to allow DBNull or have a meaningful default, otherwise the constraint will fail on the next integrity check.
Code Example
using System;
using System.Data;
var dataSet = new DataSet("Company");
var parent = dataSet.Tables.Add("Department");
parent.Columns.Add("DepartmentId", typeof(int));
parent.Columns.Add("DepartmentName", typeof(string));
parent.PrimaryKey = new[] { parent.Columns["DepartmentId"] };
var child = dataSet.Tables.Add("Employee");
child.Columns.Add("EmployeeId", typeof(int));
child.Columns.Add("DepartmentId", typeof(int)); // foreign key column
child.Columns.Add("EmployeeName", typeof(string));
// Create the constraint explicitly so it can be named and configured.
var fk = new ForeignKeyConstraint(
"FK_Employee_Department",
parent.Columns["DepartmentId"],
child.Columns["DepartmentId"])
{
DeleteRule = Rule.Cascade,
UpdateRule = Rule.Cascade,
AcceptRejectRule = AcceptRejectRule.Cascade
};
child.Constraints.Add(fk);
// Add the navigation relation without creating a second constraint.
dataSet.Relations.Add(new DataRelation(
"Department_Employee",
parent.Columns["DepartmentId"],
child.Columns["DepartmentId"],
createConstraint: false));
parent.Rows.Add(1, "Engineering");
parent.Rows.Add(2, "Sales");
// Valid: a parent row with DepartmentId 1 exists.
child.Rows.Add(101, 1, "Ada Lovelace");
try
{
// No parent has DepartmentId 99, so the row is rejected.
child.Rows.Add(102, 99, "Grace Hopper");
}
catch (ConstraintException ex)
{
Console.WriteLine($"Row rejected: {ex.Message}");
}
// DeleteRule.Cascade removes the employee row along with the department.
parent.Rows.Find(1).Delete();
When the data comes from SQL Server, fill the parent table before the child table with a SqlDataAdapter from Microsoft.Data.SqlClient; otherwise the constraint rejects child rows whose parents have not been loaded yet.
Best Practices and Common Pitfalls
The database is the source of truth. Keep the matching FOREIGN KEY constraint in SQL Server; the ForeignKeyConstraint only protects the in-memory DataSet and cannot replace server-side integrity.
Wrap edits in try/catch ConstraintException so a single invalid row does not crash a whole editing session.
Temporarily suspend enforcement for bulk loads: set dataSet.EnforceConstraints = false, fill the tables, then set it back to true — re-enabling enforcement revalidates all rows and throws ConstraintException if any still violate the rules, which you can catch and inspect.
Do not call AcceptChanges to “fix” violations.AcceptChanges simply marks rows as unchanged; it does not make orphaned children valid, and with AcceptRejectRule.Cascade it can hide real errors.
Check childTable.HasErrors and DataRow.RowError after enforcement failures to report which rows and columns caused the problem.
Remember EnforceConstraints: if constraints appear to be doing nothing, this flag has usually been turned off somewhere in the call chain.
53
Explain how to move between parent and child rows that are stored together in a DataSet.
KnownRevise
Master-detail navigation in a DataSet is performed through a DataRelation, which links a parent DataTable to a child DataTable on matching key columns. Once the relation exists, each DataRow exposes GetChildRows to reach its detail rows and GetParentRow to climb back to the parent, so you can walk the hierarchy entirely in memory without issuing new queries.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET (`System.Data`)
The DataRelation
A DataRelation is added to the DataSet.Relations collection and connects a parent column (usually a primary key) to a foreign-key column in the child table. By default, creating the relation also adds a ForeignKeyConstraint that enforces referential integrity on the in-memory data and cascades deletes or updates according to the rule you specify.
Parent table: the “master” side, such as Customers.
Child table: the “detail” side, such as Orders.
Relation name: a unique string used to retrieve the relation later or to name nested XML.
Navigating from Parent to Detail
Call DataRow.GetChildRows(DataRelation) on a parent row to receive an array of matching child rows. You can also use the overloaded GetChildRows(string relationName) or iterate the table’s ChildRelations collection when a table participates in several relations. If a parent has no children, the returned array is empty rather than null.
Navigating from Detail to Parent
Call DataRow.GetParentRow(DataRelation) on a child row to get its single parent row for that relation. GetParentRows(DataRelation) returns the same parent wrapped in a one-element array (or an empty array if there is no parent), which is convenient for uniform array-style handling. A child row has at most one parent per relation; to walk several upward relations, iterate the table’s ParentRelations collection and call GetParentRow for each.
Code Example
using System;
using System.Data;
DataSet dataSet = new DataSet();
DataTable customers = dataSet.Tables.Add("Customers");
DataTable orders = dataSet.Tables.Add("Orders");
// add columns and fill rows here (omitted for brevity)
DataRelation relation = new DataRelation(
"CustomerOrders",
customers.Columns["CustomerId"],
orders.Columns["CustomerId"]);
dataSet.Relations.Add(relation);
foreach (DataRow customer in customers.Rows)
{
Console.WriteLine(customer.Field<string>("CompanyName"));
DataRow[] orderRows = customer.GetChildRows(relation);
foreach (DataRow order in orderRows)
{
// Use nullable access if the column can contain DBNull
int? orderId = order.Field<int?>("OrderId");
Console.WriteLine($" Order {orderId}");
}
}
// From detail back to master
DataRow firstOrder = orders.Rows[0];
DataRow parent = firstOrder.GetParentRow(relation);
string name = parent.Field<string>("CompanyName");
Best Practices and Pitfalls
Name relations explicitly so GetChildRows("CustomerOrders") is self-documenting and stable if column order changes.
Use Field<T?> for nullable columns: DataRow.Field<int>() throws InvalidCastException on DBNull; prefer Field<int?>() and check HasValue.
Do not call AcceptChanges after DataAdapter.Update: Update already calls AcceptChanges on each saved row, so calling it again is redundant.
Check relation existence before navigating if the DataSet may be built dynamically; accessing a missing relation name throws at runtime.
Remember navigation is in-memory only: the DataSet does not lazy-load from the database, so ensure the relevant tables are filled before walking relations.
Avoid DataSet binary serialization (RemotingFormat = Binary): it relies on BinaryFormatter, which is obsolete and removed in .NET 9; use XML or JSON instead.
54
Can you explain what LINQ to DataSet is and demonstrate how to query the contents of a DataSet with it?
KnownRevise
LINQ to DataSet is a component of ADO.NET that enables you to write Language Integrated Query (LINQ) expressions against data stored in a DataSet or DataTable. It bridges disconnected, in-memory relational data and C# query syntax by exposing rows as enumerable objects through extension methods.
LINQ to DataSet allows you to query data that has already been fetched from a database and held in memory, rather than querying the database itself. It is part of the System.Data.DataSetExtensions assembly, which is included in the .NET 8 shared framework, so no extra NuGet package is required for most workloads. This is useful when you need to filter, sort, or project data locally after loading it, or when working with data from multiple sources merged into a single DataSet.
Key Extension Methods
The feature relies on extension methods defined in System.Data.DataRowExtensions and System.Data.DataTableExtensions:
AsEnumerable(): Converts a DataTable into an IEnumerable<DataRow>, which is the entry point for LINQ queries.
Field<T>(): Provides strongly-typed access to column values on a DataRow. It handles conversion and boxing more cleanly than the DataRow indexer.
SetField<T>(): Assigns a value to a column, automatically converting null to DBNull.Value.
CopyToDataTable(): Materializes the results of a LINQ query back into a new DataTable.
Code Example
The following example loads data securely using Microsoft.Data.SqlClient and then filters it with LINQ. Note the explicit parameter type and the nullable Field<int?> usage.
using System.Data;
using System.Linq;
using Microsoft.Data.SqlClient;
var dataSet = new DataSet();
using (var connection = new SqlConnection(connectionString))
using (var command = new SqlCommand("SELECT Id, Name, Age FROM Users WHERE Age >= @minAge", connection))
{
// Explicit type prevents SQL injection and type inference issues
command.Parameters.Add("@minAge", SqlDbType.Int).Value = 18;
connection.Open();
using (var adapter = new SqlDataAdapter(command))
{
adapter.Fill(dataSet, "Users");
}
}
DataTable? table = dataSet.Tables["Users"];
if (table is null) return;
// LINQ to DataSet query over disconnected data
var adults = from row in table.AsEnumerable()
where row.Field<int?>("Age") >= 18
select new { Id = row.Field<int>("Id"), Name = row.Field<string>("Name") };
Best Practices and Pitfalls
DBNull handling: row.Field<int>("Age") throws an InvalidCastException if the database value is NULL. Use a nullable type like Field<int?> for columns that allow nulls, or check row.IsNull("Age") first.
Filter early: LINQ to DataSet operates on data already in memory. For large datasets, filter in SQL first to reduce memory usage and network traffic, then use LINQ for final projection or in-memory joins.
Deferred execution: The query is not executed until you enumerate adults (e.g., via ToList() or a foreach loop).
No SQL injection risk: Since the data is disconnected, LINQ queries cannot be injected into the database, but you must still use parameterized queries when filling the DataSet, as shown above.
Typed DataSets: While untyped DataSet objects are common, generated typed DataSets provide compile-time safety for column names, reducing runtime string errors.
55
What are the key differences in applying LINQ queries to a DataSet versus a DataReader within ADO.NET?
KnownRevise
The fundamental distinction is that LINQ to DataSet queries cached in-memory tables, while LINQ over a DataReader streams rows from an open database connection. DataSet supports direct strongly-typed field access via Field<T>, whereas DataReader requires casting records and manual null checks.
A DataSet is disconnected; data is fully loaded into memory before LINQ executes. This allows multiple passes and random access. A DataReader is connected and forward-only. LINQ queries against it are deferred but must be enumerated while the connection remains open and the reader is active.
Key Differences
Aspect
LINQ to DataSet
LINQ to DataReader
Data location
Fully materialized in memory
Streamed from the database
Connection
Not required during query
Must stay open during enumeration
Access pattern
Random access, multiple passes
Forward-only, single pass
Field access
row.Field<T>("Column")
r["Column"] with IsDBNull checks
DBNull handling
Field<T?> returns null for nulls
Manual IsDBNull check required
Memory
Proportional to full result set
One row at a time
Package
Requires System.Data.DataSetExtensions
Built into System.Data
LINQ to DataSet Mechanics
To query a DataTable, call AsEnumerable() from System.Data.DataSetExtensions. This returns IEnumerable<DataRow>, enabling standard LINQ operators. Use row.Field<T>("Column") for type-safe access. This method handles conversion and throws if the value is DBNull unless a nullable type is used.
LINQ to DataReader Mechanics
DbDataReader implements the non-generic IEnumerable, enabling reader.Cast<IDataRecord>(). This projects each record into LINQ-compatible objects without loading everything into memory. However, column access uses indexers (r["Name"]), requiring explicit DBNull checks or IsDBNull calls to avoid runtime exceptions.
Performance and Memory Implications
DataSet consumes memory proportional to the entire result set but offers fast repeated querying. DataReader minimizes memory footprint and starts returning data immediately, ideal for large datasets. However, holding a connection open during LINQ enumeration increases database lock duration and connection pool pressure.
Best Practices and Pitfalls
Dispose Resources: Ensure DataReader and SqlConnection are wrapped in using blocks; enumeration happens inside the block.
Handle DBNull: Use Field<int?>() for DataSets and IsDBNull() for Readers to prevent invalid cast exceptions.
Avoid AddWithValue: Always specify SqlDbType explicitly to prevent type inference issues and plan cache pollution.
Connection Lifetime: Do not return IQueryable backed by a DataReader from a method; materialize results before closing the connection.
56
Describe what the DataContext class does in LINQ to SQL and why it matters.
KnownRevise
The DataContext is the central class in LINQ to SQL that represents a session between your application and a SQL Server database. It manages the database connection, translates LINQ queries into SQL, tracks changes to the entity objects it materializes, and persists those changes back to the database when you call SubmitChanges. It matters because it is the single coordination point that lets you work with relational data as objects while keeping identity, consistency, and persistence logic in one place.
Read full answerHide full answer
Applies toLINQ to SQL (System.Data.Linq) on .NET Framework 4.8; deprecated and unavailable on .NET Core/.NET 5+ (including .NET 8). Replaced by DbContext in EF Core 8.
Core Role
DataContext acts as a combined unit of work and identity map. By default, every entity object you query through a DataContext is tracked by that instance, and the DataContext guarantees that a given database row is represented by exactly one object instance within its lifetime. It is the object through which you both read data (via LINQ queries over Table<T> properties) and write data (via InsertOnSubmit, DeleteOnSubmit, and SubmitChanges). Without it, you would have to write SQL manually, track object state yourself, and ensure that two queries for the same row do not produce conflicting copies.
Key Responsibilities
Connection management: Holds the connection string or SqlConnection and opens/closes the underlying connection as needed for each query or submit operation.
Query translation: Converts LINQ expression trees into parameterized T-SQL statements executed against SQL Server.
Change tracking: Records the original values of every entity it materializes so it can generate correct INSERT, UPDATE, and DELETE commands.
Identity mapping: Ensures that two queries returning the same primary key yield the same object reference within the same DataContext.
Unit of work: Batches all pending changes and sends them in a single SubmitChanges call, wrapped in a transaction by default.
Lifecycle and Usage Pattern
The recommended pattern is short-lived DataContext instances, typically one per request or per unit of work:
Construct a DataContext with a connection string or SqlConnection.
Query data through Table<T> properties exposed on the DataContext.
Modify, add, or remove entities as needed.
Call SubmitChanges once to persist everything.
Dispose the DataContext (it implements IDisposable).
Keeping a DataContext alive for too long causes it to accumulate tracked objects, increasing memory usage and slowing down SubmitChanges because change detection must scan every tracked entity.
Deprecation and Replacement
LINQ to SQL is deprecated; Microsoft stopped developing it after .NET Framework 4 and it is not available on .NET Core, .NET 5+, or .NET 8. For new development on .NET 8, use EF Core 8 with DbContext.
Aspect
LINQ to SQL (DataContext)
EF Core 8 (DbContext)
Support status
Deprecated, .NET Framework only
Actively developed, cross-platform
Database support
SQL Server only
SQL Server, PostgreSQL, SQLite, Oracle, and more
Change tracking
Full, per-instance
Full, per-instance, with AsNoTracking for reads
Migrations
None built in
Code-first migrations built in
Recommended for new work
No
Yes
Best Practices and Common Pitfalls
Treat it as short-lived: Do not store a DataContext in a static field or reuse it across many requests; create a new instance per unit of work.
Call SubmitChanges once: Batching all modifications into a single call is more efficient than calling it after each change.
Disable tracking for read-only queries when possible: Setting ObjectTrackingEnabled = false improves performance for scenarios that never submit changes; in EF Core the equivalent is AsNoTracking().
Do not start new projects with LINQ to SQL: It is not actively developed and receives no feature updates. Migrate existing code to EF Core 8, whose DbContext provides the same unit-of-work and change-tracking role with active support.
57
What is the recommended approach for executing non-blocking database commands and readers in ADO.NET?
KnownRevise
ADO.NET performs asynchronous data operations through the Task-based asynchronous pattern (TAP) exposed on connection and command objects, allowing the calling thread to return to the pool while waiting for the database. Instead of using blocking calls like Open or ExecuteReader, you call their Async counterparts, such as OpenAsync and ExecuteReaderAsync, which return Task objects that you await.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x
Core Async Methods
The DbConnection, DbCommand, and DbDataReader base classes define the asynchronous API, which SqlConnection, SqlCommand, and SqlDataReader implement in the Microsoft.Data.SqlClient provider.
OpenAsync: Opens the database connection without blocking the thread.
ExecuteReaderAsync: Executes a query and returns a reader as a Task<DbDataReader>.
ExecuteNonQueryAsync: Runs INSERT, UPDATE, or DELETE statements asynchronously.
ExecuteScalarAsync: Retrieves the first column of the first row asynchronously.
ReadAsync: Advances the reader to the next row without blocking.
Most of these methods accept a CancellationToken, enabling you to cancel long-running queries or respond to request timeouts in web applications.
Why Use Asynchronous I/O
Async I/O does not make a single query execute faster; the database still takes the same amount of time to process the request. The benefit is scalability: while the I/O is in progress, no thread is blocked waiting for it. In ASP.NET Core applications, this frees up thread pool threads to handle other incoming requests, reducing the risk of thread pool starvation under high load. This is particularly important for web servers where thread count is limited.
Code Example
The following method opens a connection, runs a parameterized SELECT statement, and streams the results asynchronously. It uses await using to dispose of resources asynchronously and binds parameters with explicit types.
using System;
using System.Collections.Generic;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
public class UserRepository
{
public async Task<IEnumerable<(int Id, string Name)>> GetUsersAsync(
int minId, CancellationToken cancellationToken = default)
{
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION")
?? throw new InvalidOperationException("Connection string missing");
var results = new List<(int Id, string Name)>();
await using var connection = new SqlConnection(connectionString);
await using var command = new SqlCommand(
"SELECT Id, Name FROM Users WHERE Id >= @minId", connection);
// Bind with explicit type; never use AddWithValue
command.Parameters.Add("@minId", SqlDbType.Int).Value = minId;
await connection.OpenAsync(cancellationToken);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
results.Add((reader.GetInt32(0), reader.GetString(1)));
}
return results;
}
}
Best Practices and Common Pitfalls
Async all the way: Once you call an async method, propagate async and await up the call stack. Never block on a Task with .Result or .Wait(), as this can cause deadlocks in contexts with a synchronization context and defeats the scalability benefit.
Pass cancellation tokens: Always forward the CancellationToken from your controller or API endpoint so operations can be aborted if the client disconnects.
Connection pooling: Async opens still use the standard connection pool, so keep connection strings identical to maximize pool reuse.
Avoid fire-and-forget: Do not start a database Task without awaiting it; unobserved exceptions can crash the process in some hosting configurations.
Explicit parameter types: Always add parameters with Parameters.Add and a SqlDbType, as shown above, rather than AddWithValue, to ensure correct query plans and type safety.
58
What effect do the async and await keywords have on database operations within ADO.NET?
KnownRevise
The asynchronous I/O model prevents thread pool starvation by releasing threads back to the pool while waiting for database network responses. Instead of blocking a thread during latency, the runtime registers a callback and resumes execution when the operation completes. This is critical for high-concurrency web applications where synchronous blocking can exhaust available threads.
When a synchronous method like ExecuteReader is called, the calling thread blocks until the SQL Server responds. In contrast, async methods utilize operating system I/O completion ports on Windows or epoll on Linux. The thread returns to the ThreadPool immediately after initiating the request. Once the database sends data, the runtime schedules the continuation on an available thread, allowing fewer threads to handle more concurrent requests.
Core Async Methods
Microsoft.Data.SqlClient provides async counterparts for most synchronous operations. Key methods include OpenAsync, ExecuteReaderAsync, ExecuteNonQueryAsync, and ExecuteScalarAsync. Data readers also support ReadAsync, NextResultAsync, and GetFieldValueAsync<T>. These methods accept an optional CancellationToken to allow external cancellation, such as when a web request is abandoned by the client.
Scalability and Responsiveness
In ASP.NET Core, using async database calls allows the web server to handle thousands of concurrent connections with a limited thread pool. Synchronous blocking under load leads to thread pool starvation, causing request queuing and timeouts. For desktop apps like WPF or .NET MAUI, async operations keep the UI thread free, preventing the interface from freezing during long-running queries.
Best Practices and Pitfalls
Always await async calls: Never use .Result or .Wait() as this can cause deadlocks, especially in UI contexts.
Use ConfigureAwait(false) in library code: Avoids unnecessary context capture, though this is less critical in modern ASP.NET Core.
Propagate CancellationToken: Pass tokens from the caller to support timeouts and cooperative cancellation.
Avoid mixing sync and async on the same connection: Doing so without proper handling can lead to race conditions or connection state errors.
Async transactions: Use BeginTransactionAsync, CommitAsync, and RollbackAsync when working with transactions in an async flow.
59
Which members of SqlCommand let you run database operations asynchronously, and how do you use them?
KnownRevise
SqlCommand exposes asynchronous versions of its four execution methods — ExecuteNonQueryAsync, ExecuteScalarAsync, ExecuteReaderAsync and ExecuteXmlReaderAsync — plus the legacy BeginExecute*/EndExecute* pairs, so a .NET application can issue commands without blocking a thread while SQL Server works.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and the `Microsoft.Data.SqlClient` NuGet package
Asynchronous Methods
The Task-based methods are defined on DbCommand and overridden in SqlCommand; they are the ones to use with async/await:
ExecuteNonQueryAsync: runs a command that returns no result set (INSERT, UPDATE, DELETE, DDL, stored procedures without a result), returning the number of rows affected.
ExecuteScalarAsync: runs a query and returns the first column of the first row as an object, discarding the rest; useful for aggregate lookups such as COUNT(*).
ExecuteReaderAsync: returns a SqlDataReader for streaming result sets. Overloads accept a CommandBehavior and/or a CancellationToken.
ExecuteXmlReaderAsync: returns an XmlReader when the command produces XML, for example a query using FOR XML.
BeginExecuteNonQuery / EndExecuteNonQuery, BeginExecuteReader / EndExecuteReader, BeginExecuteXmlReader / EndExecuteXmlReader: the older Asynchronous Programming Model. They still compile, but they are legacy; use the *Async methods in new code.
How It Works
Each *Async method yields the calling thread back to the thread pool until SQL Server responds, which is what makes them valuable in server applications: a blocked thread costs memory and reduces throughput, while an awaited operation holds almost no managed resources. The methods are true async I/O operations against the connection, not work queued to the thread pool.
Cancellation is supported through a CancellationToken passed to the overloads that accept one. SQL Server honors the cancellation for most running commands, and the operation surfaces as an OperationCanceledException (or SqlException for some server-side aborts) when the token fires.
Comparing the Execution Methods
Method
Returns
Typical Use
ExecuteNonQueryAsync
Task<int>
INSERT/UPDATE/DELETE, DDL, no-result procedures
ExecuteScalarAsync
Task<object>
Single-value lookups, aggregates
ExecuteReaderAsync
Task<SqlDataReader>
Streaming one or more result sets
ExecuteXmlReaderAsync
Task<XmlReader>
FOR XML queries
Code Example
using System.Data;
using Microsoft.Data.SqlClient;
// Connection strings should come from configuration, never hard-coded.
string connectionString = "Server=.;Database=Shop;Trusted_Connection=True;TrustServerCertificate=True;";
await using SqlConnection connection = new(connectionString);
await connection.OpenAsync(); // OpenAsync is the async counterpart of Open
await using SqlCommand command = connection.CreateCommand();
command.CommandText = "SELECT Name FROM Customers WHERE Region = @region";
command.Parameters.Add("@region", SqlDbType.NVarChar, 50).Value = "West";
// ExecuteReaderAsync streams the rows; CommandBehavior.SingleRow is a hint for one-row queries.
await using SqlDataReader reader =
await command.ExecuteReaderAsync(CommandBehavior.SingleRow, CancellationToken.None);
while (await reader.ReadAsync()) // ReadAsync avoids blocking per row
{
string name = reader.GetString(0);
Console.WriteLine(name);
}
For a command that modifies data, replace the reader block with:
command.CommandText = "UPDATE Customers SET Name = @name WHERE Id = @id";
command.Parameters.Add("@name", SqlDbType.NVarChar, 100).Value = "Contoso";
command.Parameters.Add("@id", SqlDbType.Int).Value = 42;
int rows = await command.ExecuteNonQueryAsync();
Best Practices and Pitfalls
Prefer OpenAsync with await using: connections, commands and readers are all IAsyncDisposable in .NET 8, so await using disposes them asynchronously.
Never block on async code: calling .Result or .Wait() on these tasks can deadlock in UI or ASP.NET classic synchronization contexts and defeats the purpose of the async call.
Pass a real CancellationToken from the request or page lifetime rather than CancellationToken.None, so long-running queries can be cancelled cleanly.
Do not mix one connection with concurrent commands: a single SqlConnection handles one active command at a time unless MARS is enabled; run parallel commands on separate connections.
SqlDataAdapter has async methods too: Microsoft.Data.SqlClient includes SqlDataAdapter.FillAsync and UpdateAsync; remember that Update calls AcceptChanges on each saved row automatically, so don’t call AcceptChanges yourself.
Remember DBNull.Value: a column containing NULL reads as DBNull, not C# null, so check reader.IsDBNull(0) before calling typed accessors.
60
What approach would you use to make an ADO.NET application work with multiple database systems without rewriting the data-access code?
KnownRevise
The standard approach is to program against the provider-agnostic abstract base classes in System.Data.Common — DbProviderFactory and the objects it produces (DbConnection, DbCommand, DbDataReader, DbParameter) — so the concrete database provider is resolved from configuration instead of being hardcoded in the data-access layer.
Read full answerHide full answer
Applies to.NET 8 and C# 12 (provider-agnostic ADO.NET via `System.Data.Common`; SQL Server examples use Microsoft.Data.SqlClient 5.x)
Provider-Agnostic Base Classes
System.Data.Common defines abstract base classes that every ADO.NET provider implements:
DbConnection: represents the database connection; created per provider via the factory.
DbCommand: holds the SQL text and parameters; created via connection.CreateCommand().
DbParameter: represents a parameter; created via command.CreateParameter(), with DbType, Size, Precision and Scale set explicitly.
DbDataReader: forward-only result stream; returned by ExecuteReader().
DbTransaction and DbDataAdapter: for transactions and disconnected DataSet fills.
Because your code references only these base types, swapping providers requires no code changes — only a different provider package and connection string.
Resolving the Provider with DbProviderFactory
DbProviderFactories.GetFactory(string invariantName) returns the factory registered for a provider invariant name. Critical for .NET 8: unlike .NET Framework, .NET Core and later do not read machine.config, so providers from NuGet packages are not auto-registered merely by referencing the assembly. You must register each factory explicitly at startup with DbProviderFactories.RegisterFactory(invariantName, factory) (for example SqlClientFactory.Instance for SQL Server or NpgsqlFactory.Instance for PostgreSQL), or resolve the provider through dependency injection instead. GetFactory throws ArgumentException if the name is not registered.
Database
Provider invariant name
NuGet package
SQL Server
Microsoft.Data.SqlClient
Microsoft.Data.SqlClient
PostgreSQL
Npgsql
Npgsql
SQLite
Microsoft.Data.Sqlite
Microsoft.Data.Sqlite
Oracle
Oracle.ManagedDataAccess.Core
Oracle.ManagedDataAccess.Core
In modern .NET 8 applications, many teams register the DbConnection (or a repository interface) directly in the DI container, which avoids DbProviderFactory registration entirely and makes the provider choice explicit at the composition root.
Configuration and Connection Strings
Store the provider name and connection string in configuration (e.g. appsettings.json), and keep secrets out of source code using user secrets, environment variables, or Azure Key Vault. Note that SQL dialects are not fully portable: TOP, identity columns, and square-bracket quoting are SQL Server-specific, while PostgreSQL uses LIMIT, SERIAL, and double quotes. Keep SQL provider-specific or use a query-per-provider strategy if you must support divergent dialects.
Limitations and Best Practices
Provider-specific features are not portable: SqlBulkCopy, table-valued parameters, and provider-specific types require concrete classes and break the abstraction.
Data type mappings differ: DbType.DateTime2, precision, and scale behave differently across providers; test each provider explicitly.
Parameter naming varies: SQL Server and PostgreSQL use named parameters (@name), while Oracle binds by position unless BindByName = true is set.
Never use AddWithValue: always set DbType, Size, Precision and Scale explicitly via CreateParameter().
Validate the invariant name before calling GetFactory, and wrap provider resolution so an unsupported provider fails fast with a clear message.
Prefer DI in new .NET 8 code: DbProviderFactory still works but requires manual registration; DI gives compile-time safety and easier testing.
61
What steps are involved in querying a MySQL database using ADO.NET within a contemporary .NET solution?
KnownRevise
Using ADO.NET with MySQL requires selecting a compatible data provider that implements the base ADO.NET classes, typically MySqlConnector for modern .NET applications. Instead of the SQL Server-specific Microsoft.Data.SqlClient, you use MySQL-specific implementations of DbConnection, DbCommand, and DbParameter while retaining the same ADO.NET patterns for connection management and parameterization.
Read full answerHide full answer
Applies to.NET 8, C# 12, MySqlConnector 2.x
Provider Selection
ADO.NET is a set of interfaces and base classes in System.Data, but database-specific logic lives in provider libraries. For MySQL on .NET 8, MySqlConnector is the preferred high-performance provider over the legacy Oracle MySql.Data package because it fully supports async/await without blocking threads. You install it via NuGet (MySqlConnector) and reference using MySqlConnector; in your data access layer.
Connection and Command Structure
You establish a connection using MySqlConnection with a standard connection string containing server, database, and credentials. Commands are created via MySqlCommand, linked to the connection, and configured with a SQL statement. Always open the connection explicitly before executing commands and ensure it closes afterward, ideally through using statements which guarantee disposal even if exceptions occur.
Parameterized Queries
Security is critical; never concatenate user input into SQL strings. Use parameters to prevent SQL injection and ensure correct type mapping. Following strict typing rules, every parameter is added with an explicit MySqlDbType rather than relying on type inference helpers like AddWithValue, which can cause performance issues or type mismatches. For nullable columns, pass DBNull.Value instead of C# null to correctly represent database NULLs.
Data Retrieval
Execute queries using ExecuteReader to obtain a MySqlDataReader, which streams rows efficiently. The reader implements IEnumerable, allowing LINQ integration via Cast<IDataRecord>() if needed, though manual iteration is often clearer for mapping objects. Read columns by ordinal, or by name by first calling GetOrdinal, and check for DBNull before converting values to avoid invalid cast exceptions during data processing.
Code Example
using System.Threading.Tasks;
using MySqlConnector;
public sealed class UserRepository
{
public async Task<User?> GetUserAsync(int userId, string connectionString)
{
await using var connection = new MySqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new MySqlCommand(
"SELECT id, name, email FROM users WHERE id = @id", connection);
// Explicit type declaration instead of AddWithValue, which infers the type
command.Parameters.Add("@id", MySqlDbType.Int32).Value = userId;
await using var reader = await command.ExecuteReaderAsync();
if (await reader.ReadAsync())
{
// MySqlDataReader has no GetXxx(string) overloads: resolve ordinals first
int idOrdinal = reader.GetOrdinal("id");
int nameOrdinal = reader.GetOrdinal("name");
int emailOrdinal = reader.GetOrdinal("email");
return new User
{
Id = reader.GetInt32(idOrdinal),
Name = reader.GetString(nameOrdinal),
Email = reader.IsDBNull(emailOrdinal) ? null : reader.GetString(emailOrdinal)
};
}
return null;
}
}
public sealed class User
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Email { get; set; }
}
Common Pitfalls
Avoid leaving connections open by relying on garbage collection; always use using blocks. Do not mix MySqlConnector types with Microsoft.Data.SqlClient types, as they are incompatible despite sharing base classes. Remember that MySqlCommand parameters are bound by name by default, unlike ODP.NET which binds by position. Finally, ensure your connection string uses SslMode=Required in production to encrypt traffic between the application and the database server.
62
What distinguishes Entity Framework from plain ADO.NET as a data access technology?
KnownRevise
ADO.NET is the low-level, provider-based data access layer of .NET that exposes connections, commands, readers and datasets, while Entity Framework (the current, officially recommended line being EF Core 8) is an object-relational mapper that sits on top of ADO.NET and lets you work with strongly typed .NET objects and LINQ instead of hand-written SQL and result-set mapping.
Read full answerHide full answer
Applies to.NET 8 with C# 12; EF Core 8; ADO.NET through the `Microsoft.Data.SqlClient` provider for SQL Server.
What Each Technology Is
ADO.NET: the foundation of all .NET data access. You open a SqlConnection, create a SqlCommand, bind parameters with an explicit SqlDbType, execute the command, and read results with a SqlDataReader or load them into a DataTable/DataSet. You write the SQL, you map the rows to objects yourself, and you control connection lifetime, transactions and batching explicitly.
Entity Framework: an ORM that models the database as .NET entity classes plus a DbContext. You query with LINQ (context.Products.Where(p => p.Price > 10)), and EF Core translates that to SQL, executes it through an ADO.NET provider, and materialises the results as objects. Changes to entities are tracked and saved with SaveChanges, which generates the INSERT/UPDATE/DELETE statements.
The legacy “ADO.NET Entity Framework” branding refers to EF6, which is in maintenance mode and runs only on .NET Framework. New development should use EF Core 8 unless an EF6-only feature is required.
Key Differences
Aspect
ADO.NET
Entity Framework (EF Core 8)
Abstraction level
Provider API: connections, commands, readers
ORM over a provider; entities and DbContext
Query language
Raw SQL strings and stored procedures
LINQ queries, plus raw SQL when needed
Result mapping
Manual: read columns by ordinal/name and build objects
Automatic materialisation into tracked entities
Change tracking
None — you write your own UPDATE logic
SaveChanges diffs tracked entities and writes the SQL
Migrations
Database is created and versioned by scripts/tooling
Code-first migrations generate schema changes
Productivity
Slower for CRUD-heavy models
Much faster for typical business applications
Control and performance
Maximum control, minimal overhead, predictable SQL
Convenience layer; generated SQL can be inspected but is less direct
EF Core is not a replacement for ADO.NET; it is a consumer of it. Every EF Core provider wraps an ADO.NET provider — for SQL Server, Microsoft.EntityFrameworkCore.SqlServer uses Microsoft.Data.SqlClient internally. When you call SaveChanges, EF Core opens a SqlConnection, enlists it in a DbTransaction, and issues parameterised commands through the same ADO.NET classes you would use by hand. This is why provider-specific behaviour (connection string settings, distributed transaction limits, bulk copy semantics) still applies underneath EF Core.
When to Choose Which
Prefer EF Core 8 for line-of-business applications with many tables, relationships and CRUD screens, where developer productivity, compile-time queries and migrations outweigh micro-optimisation.
Prefer plain ADO.NET for high-throughput read paths, reporting, bulk loads (SqlBulkCopy), complex stored-procedure logic, or when you need exact control over the SQL and round trips.
Combine them: EF Core 8 supports dropping down to ADO.NET for specific hotspots — execute raw SQL via context.Database.SqlQueryRaw<T>() for mapped shapes, or FromSqlRaw/FromSqlInterpolated on a DbSet, while keeping Microsoft.Data.SqlClient available for a raw SqlConnection when a query cannot be expressed through the model.
Best Practices and Common Pitfalls
Always parameterise queries in both stacks; in raw ADO.NET use command.Parameters.Add("@id", SqlDbType.Int).Value = id and never AddWithValue, which infers types and can cause bad query plans.
Watch for EF Core performance traps: lazy-loading proxies cause N+1 queries, and SaveChanges sends one command per changed row. Use eager loading (Include) or split queries for graphs, and ExecuteUpdate/ExecuteDelete (bulk updates) in EF Core 7+ when you do not need to load entities first.
Dispose everything: wrap SqlConnection, SqlCommand and SqlDataReader in using blocks; in EF Core the DbContext should be scoped or explicitly disposed so connections are returned to the pool.
Do not treat EF Core as a reason to skip SQL knowledge — reading the generated SQL with LogTo (configured on the DbContextOptionsBuilder) or ToQueryString() is essential for diagnosing slow queries.
63
Can you explain what the Code First approach in Entity Framework is and describe how it interacts with ADO.NET underneath?
KnownRevise
The Code First approach in Entity Framework Core is a development workflow where you define your data model as plain C# classes (entities) and let the framework generate and evolve the database schema from those classes, rather than designing the database first. Under the hood, EF Core is an ORM that sits on top of ADO.NET: it translates your LINQ queries and change-tracking operations into SQL and executes them through the ADO.NET provider configured for your database.
Read full answerHide full answer
Applies toEF Core 8 (.NET 8) with Microsoft.Data.SqlClient for SQL Server
What Code First Is
Model as code: You write entity classes with navigation properties that represent tables and relationships.
Schema generation: EF Core creates the database and tables automatically, either at first use (for development) or through migrations.
Migrations: Schema changes are captured as incremental migration files that can be reviewed, scripted and applied to different environments.
Convention over configuration: Naming conventions infer primary keys, foreign keys and column names, though you can override them with data annotations or the fluent API in OnModelCreating.
How It Builds on ADO.NET
EF Core does not replace ADO.NET; it consumes it. Each database provider (for example the SQL Server provider) is implemented on top of the ADO.NET provider model: - DbConnection: EF Core opens and closes connections through the provider’s connection object. - DbCommand: Generated SQL is sent to the server via commands, with parameters added using explicit types. - DbDataReader: Query results are materialized from a streaming reader into tracked entity objects. - DbTransaction: SaveChanges wraps commands in a transaction when multiple writes occur.
You never see these objects in typical Code First code, but they are the transport layer EF Core relies on.
Core Components
Component
Role in Code First
DbContext
Session with the database; tracks changes and coordinates saves
DbSet<TEntity>
Represents a table and enables LINQ queries against an entity type
Entity classes
POCOs mapped to tables; properties map to columns
ModelBuilder
Configures mappings, keys, relationships and column types
Migrations
Versioned schema scripts generated from model changes
When Raw ADO.NET Is Useful
Code First covers most scenarios, but dropping to ADO.NET is appropriate for bulk operations, database-specific features EF Core does not model, or performance-critical queries where you want full control over SQL and parameters. You can reach the underlying connection through DbContext.Database.GetDbConnection() and create commands from it. Note that if you open the connection yourself, EF Core will not close it for you.
Best Practices and Pitfalls
Never use AddWithValue: always add parameters with an explicit SqlDbType and Size where relevant.
Migrations in production: generate SQL scripts (Script-Migration) and review them before applying to production databases.
Connection ownership: when you manually open the connection for raw ADO.NET, dispose or close it yourself; EF Core only manages connections it opens.
Change tracking cost: for read-only reporting queries, use AsNoTracking() to avoid the overhead of tracking entities.
Know the abstraction: Code First is convenient, but understanding the ADO.NET layer helps you diagnose performance issues, parameter sniffing and transaction behaviour.
64
Describe how the Database First approach functions within Entity Framework and when it is the right choice.
KnownRevise
The Database First approach is a workflow in which an existing database schema drives the creation of the application model: Entity Framework reverse-engineers the database to generate entity classes and a DbContext that map to the schema, rather than requiring you to write those classes by hand.
Read full answerHide full answer
Applies toEF Core 8 on .NET 8 (using Microsoft.EntityFrameworkCore.SqlServer)
How It Works
Database First in EF Core is implemented through scaffolding (reverse engineering). In practice you run dotnet ef dbcontext scaffold with a connection string and the provider package; the tooling reads system catalog views to discover tables, columns, primary keys, foreign keys, and constraints, then emits C# code that represents that structure. This is the natural fit when the database already exists and is managed independently of the application—for example, in legacy modernization projects or in environments where database administrators own the schema.
Note that the visual EDMX-based workflows (Model First / Database First) available in EF6 are not supported in EF Core; scaffolding is the supported equivalent for existing databases.
Generated Artifacts
Scaffolding produces three main artifacts that compose the data access layer:
Entity Classes: Plain C# objects (POCOs) whose properties match table columns. By default they are mapped via the Fluent API; data annotations are generated only if you pass the --data-annotations flag.
DbContext: A derived context class with a DbSet<T> property for each table and an OnConfiguring method that wires up the connection string (unless you suppress it).
Fluent API Mapping: The OnModelCreating method holds the detailed configuration—keys, relationships, column types, nullability—that cannot be expressed (or is not expressed) through data annotations.
Database First vs Code First
Feature
Database First
Code First
Source of truth
Existing database schema
C# entity classes
Schema changes
Update the database, then rescaffold or edit the model manually
Edit the model, then generate and apply Migrations
Greenfield applications, domain-driven design, teams that want schema under source control
Initial setup
Fast, automatic generation
Slower, requires manual class and configuration writing
When It Is the Right Choice
Choose Database First when: - The database already exists and is the authoritative source of truth. - The schema is maintained by a DBA or another team and changes are applied outside your codebase. - You are modernizing a legacy application and want to avoid rewriting the schema. - You need to integrate with a third-party or vendor-managed database.
Avoid it for greenfield projects where you want the model to drive schema evolution, because Code First with Migrations gives you version-controlled, repeatable schema changes.
Best Practices and Pitfalls
Use partial classes: Generated files are overwritten on rescaffold. Extend entities or the context with partial class definitions in separate files to keep custom logic safe.
Treat generated code as read-only: Rescaffolding regenerates files and discards manual edits inside them.
Control naming: Plural table names or reserved keywords can produce awkward C# identifiers; use --namespace, --table, and schema options to shape the output.
Beware migration drift: You can enable Migrations after scaffolding in EF Core 8, but applying them to a database managed externally can cause the schema and model to diverge.
Protect secrets: Never commit connection strings with credentials to source control. Use integrated security, user secrets, or environment variables, and keep the connection string out of the command history.
65
Explain what migrations are in Entity Framework and describe how they relate to ADO.NET when the database is created or updated.
KnownRevise
Entity Framework migrations are a code-first versioning mechanism that translates changes in your EF Core model into ordered, named C# classes, and those classes are turned into provider-specific DDL that is executed against the database through the underlying ADO.NET provider — for SQL Server, that provider is Microsoft.Data.SqlClient.
Read full answerHide full answer
Applies to.NET 8, C# 12, EF Core 8 with Microsoft.Data.SqlClient
What Migrations Are
A migration is a class derived from Migration that overrides Up and Down. It records the operations needed to move the database from one model state to the next (CreateTable, AddColumn, DropTable, and so on) plus the reverse operations. Each migration is stored with a timestamp-based name and is accompanied by a model snapshot, a compiled representation of the model that EF Core uses to compute the difference between your current model and the previous migration — that diff is what dotnet ef migrations add turns into the new migration.
How the Pipeline Works
You change your entity classes or DbContext configuration.
dotnet ef migrations add AddCustomerTable compares the new model against the snapshot and generates a migration class plus an updated snapshot.
At apply time, EF Core’s migration runtime converts the migration’s operations into SQL using the provider’s MigrationsSqlGenerator.
The generated SQL is executed through the provider’s ADO.NET connection (RelationalConnection wrapping a SqlConnection for SQL Server), and each applied migration name is recorded in the __EFMigrationsHistory table.
Relationship to ADO.NET
Migrations are an EF Core feature, not an ADO.NET API — ADO.NET has no concept of a migration. The integration happens because EF Core is built on top of ADO.NET: every migration ultimately runs as plain SQL through DbConnection / DbCommand objects created by the provider. You can also break the link deliberately: generate the SQL with dotnet ef migrations script -o migrate.sql and execute it yourself with a SqlCommand, in a CI pipeline, a release step, or a database project. Note that the generated script contains GO batch separators, which a single SqlCommand.ExecuteNonQuery() call cannot execute — split the script on GO and run each batch as its own command, or use sqlcmd / SQL Server Management Studio instead.
Applying Migrations
context.Database.Migrate() — applies pending migrations at runtime; intended for development or tooling, and it creates the database if it does not exist.
context.Database.EnsureCreated() — creates the schema from the model but does not use migrations or update the __EFMigrationsHistory table; the two approaches are mutually exclusive.
Migration bundles (dotnet ef migrations bundle) — a self-contained executable that applies migrations without the application or the SDK, which is the recommended option for production deployments and CI/CD.
Approach
Uses migrations
Updates __EFMigrationsHistory
Typical use
Migrate()
Yes
Yes
Development, tools, single-instance apps
EnsureCreated()
No
No
Prototypes, tests that discard the database
Migration bundle / script
Yes
Yes
Production, CI/CD pipelines
Best Practices and Pitfalls
Apply migrations once, in a controlled step: calling Migrate() at application startup is fine for development, but in multi-instance production deployments several instances may race to apply the same migration; prefer a migration bundle or a release pipeline.
Never edit a migration that has already been applied to any shared database; add a new migration instead, so the snapshot stays consistent.
Review the generated SQL before it reaches production — column types, nullability, index names and IDENTITY settings are worth checking, and destructive operations such as DropColumn cause data loss.
Keep migrations small and focused so that they can be reviewed, tested and rolled back individually.
Use EnsureCreated() only for prototypes or tests that throw the database away; it will not migrate when the model changes later.
66
How are Table-Valued Parameters used when passing multiple rows of data from .NET to SQL Server?
KnownRevise
Table-Valued Parameters (TVPs) let you send a whole set of rows to SQL Server in a single parameter, instead of calling a command once per row or packing values into a delimited string. They are declared as a User-Defined Table Type on the server and passed to ADO.NET commands as a parameter whose SqlDbType is Structured, carrying a DataTable, a DbDataReader, or an IEnumerable<SqlDataRecord> as its value.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.2 and SQL Server 2008 or later (TVPs are available in all SQL Server versions since 2008, including Azure SQL Database).
What a TVP Is
A TVP is a strongly typed table-shaped parameter. Before using one, you define a User-Defined Table Type (UDTT) in the database that fixes the column names, data types, nullability and optionally primary key/unique constraints. A stored procedure or parameterized batch then declares a parameter of that type, which must be marked READONLY because TVPs are input-only.
How It Works
You create the UDTT once, e.g. dbo.CategoryTableType.
You create a command whose parameter’s SqlDbType is Structured.
You set TypeName to the fully qualified UDTT name, including the schema (dbo.CategoryTableType) — the schema is required.
You assign the value and execute; SQL Server receives the rows in one round trip.
The main alternatives — one INSERT per row, comma-separated lists parsed with STRING_SPLIT, or staging into a temp table — are either slower, weaker typed, or require more plumbing. TVPs give you one network round trip, full type checking on the server, and participation in a single transaction.
Passing Data from ADO.NET
DataTable: the simplest option; ADO.NET converts it into the TVP stream. Its columns are matched to the UDTT columns by ordinal position, so the column order and data types must match — the column names are not used.
DbDataReader: streams rows from any data reader without buffering them all in memory, which suits large row sets. Column order must match the UDTT.
IEnumerable<SqlDataRecord>: useful when you want to emit rows from your own objects without building a DataTable; you construct each SqlDataRecord with the UDTT’s column metadata.
Value type
Strengths
Caveats
DataTable
Easiest to build and inspect
Buffers all rows in memory; columns must be in UDTT order
DbDataReader
Streams, low memory
Column order must match the UDTT
IEnumerable<SqlDataRecord>
No DataTable needed
Most code to write; order must match
Code Example
CREATE TYPE dbo.CategoryTableType AS TABLE
(
Id int NOT NULL,
Name nvarchar(100) NOT NULL,
CONSTRAINT PK_CategoryTableType PRIMARY KEY (Id)
);
GO
CREATE PROCEDURE dbo.InsertCategories
@Categories dbo.CategoryTableType READONLY
AS
BEGIN
SET NOCOUNT ON;
INSERT INTO dbo.Categories (Id, Name)
SELECT Id, Name
FROM @Categories;
END
using System.Data;
using Microsoft.Data.SqlClient;
// 'categories' is the in-memory list of rows to send.
// Columns are added in the same order as in dbo.CategoryTableType.
var table = new DataTable();
table.Columns.Add("Id", typeof(int));
table.Columns.Add("Name", typeof(string));
foreach (var category in categories)
{
table.Rows.Add(category.Id, category.Name);
}
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand("dbo.InsertCategories", connection)
{
CommandType = CommandType.StoredProcedure
};
// SqlDbType.Structured marks this parameter as a TVP.
var parameter = command.Parameters.Add("@Categories", SqlDbType.Structured);
parameter.TypeName = "dbo.CategoryTableType"; // schema prefix is required
parameter.Value = table;
connection.Open();
command.ExecuteNonQuery();
To stream instead of buffering, keep the same parameter setup and assign a DbDataReader (for example a reader opened on another connection) to parameter.Value; ADO.NET reads the rows forward-only and sends them as the TVP.
Best Practices and Pitfalls
Always set TypeName with the schema prefix; an unqualified name fails to resolve.
Never use AddWithValue for a TVP — add the parameter with the explicit SqlDbType.Structured and then set TypeName and Value.
Match the UDTT column order: DataTable, DbDataReader and SqlDataRecord sequences are all matched positionally, so a reordered or retyped column silently produces wrong data or an error.
TVPs are read-only: you cannot mark them OUTPUT, and you cannot modify them inside T-SQL.
EF Core 8 has no first-class TVP mapping: use raw SQL through FromSqlRaw / ExecuteSqlRaw with a SqlParameter whose SqlDbType is Structured, or fall back to Microsoft.Data.SqlClient directly.
Watch the batch size for very large sets: a TVP is one request, so extremely large row counts can strain memory and log; benchmark against SqlBulkCopy when you need bulk-load throughput.
67
What is the approach for executing Full-Text Search queries against SQL Server from ADO.NET code?
KnownRevise
Full-Text Search is a SQL Server feature that ADO.NET accesses by sending standard T-SQL predicates such as CONTAINS, FREETEXT, CONTAINSTABLE, and FREETEXTTABLE through a SqlCommand. ADO.NET itself does not provide a special API; you set up full-text indexes on the server and then query them with parameterized commands just like any other SQL.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x and SQL Server (the Full-Text Search feature must be installed)
How It Works
Full-text search operates entirely on the SQL Server side. Your ADO.NET code only needs to:
Open a SqlConnection to a database that has full-text enabled.
Send a query containing full-text predicates in the CommandText.
Bind the user’s search phrase as a parameter (never concatenate it into the string).
Read the matching rows from the returned SqlDataReader.
Because the search logic lives in T-SQL, the same queries you would run in SQL Server Management Studio work unchanged through ADO.NET.
Prerequisites on the Server
Before querying, the database must have full-text search enabled and the target columns indexed:
A full-text catalog exists (or the default is used).
A full-text index is created on the table and column, e.g. CREATE FULLTEXT INDEX ON Articles(Body) KEY INDEX PK_Articles.
The index is populated; with change tracking on, inserts and updates are reflected automatically.
If the index is missing, SQL Server returns an error when the query runs, so confirm setup during deployment.
Predicates and Functions to Use
Construct
Use case
Returns
CONTAINS(column, 'term')
Precise word/phrase, proximity, and inflectional searches
Rows (boolean match)
FREETEXT(column, 'phrase')
Fuzzy matching on meaning, less precise
Rows (boolean match)
CONTAINSTABLE(table, column, 'term')
Precise search with relevance ranking
Table of KEY and RANK
FREETEXTTABLE(table, column, 'phrase')
Fuzzy search with relevance ranking
Table of KEY and RANK
Use CONTAINS for exact term matching and CONTAINSTABLE when you need to order results by relevance.
Code Example
using System;
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = Environment.GetEnvironmentVariable("SqlConnectionString");
string searchTerm = "database AND performance";
using var connection = new SqlConnection(connectionString);
using var command = new SqlCommand(
"SELECT Id, Title, Body FROM Articles WHERE CONTAINS(Body, @searchTerm)",
connection);
// Bind the search phrase explicitly; NVARCHAR is required for full-text parameters.
command.Parameters.Add("@searchTerm", SqlDbType.NVarChar, 4000).Value = searchTerm;
try
{
connection.Open();
using var reader = command.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($"{reader.GetInt32(0)}: {reader.GetString(1)}");
}
}
catch (SqlException ex)
{
Console.WriteLine($"Full-text search failed: {ex.Message}");
}
For ranked results, join against CONTAINSTABLE:
SELECT a.Id, a.Title, t.[RANK]
FROM Articles a
INNER JOIN CONTAINSTABLE(Articles, Body, @searchTerm) t ON a.Id = t.[KEY]
ORDER BY t.[RANK] DESC;
Best Practices and Common Pitfalls
Always parameterize the search term. Search input comes from users, and concatenating it into the command text opens SQL injection. CONTAINS accepts an NVARCHAR parameter directly.
Use NVARCHAR parameters. Full-text predicates expect Unicode; passing VarChar can cause implicit conversion issues.
Do not parameterize column or table names. Predicates require literal object names; validate and whitelist any dynamic column before building the command text.
Mind stopwords and noise words. Common words like “the” or “and” may be ignored by the full-text engine, affecting expected matches.
Check index population. Queries against an unpopulated or paused index can return no rows rather than an error, so verify population state when results look wrong.
Prefer CONTAINSTABLE for relevance ordering. Ranking is only available through the table-valued functions, not the row predicates.
68
Which techniques can you use to measure and improve the performance of data-access code built with ADO.NET?
KnownRevise
The main approach is to profile first, then optimize: measure where time actually goes with profilers and database telemetry, then apply targeted ADO.NET tuning such as connection pooling, async streaming, parameterized batched commands, read-only/sequential readers, and bulk-load APIs. Most ADO.NET performance problems are not in the data provider itself but in round-trips, chatty query patterns, blocking, or inefficient materialization.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (the supported SQL Server provider; `System.Data.SqlClient` is legacy).
Measure Before Tuning
Never optimize from guesswork. Collect data at three layers:
Managed profiler: dotTrace, PerfView, or Visual Studio’s Performance Profiler to find hotspots. Look for methods dominated by SqlDataReader materialization, DataTable loading, or synchronous Read() calls on the UI thread.
Database telemetry: SQL Server Extended Events (the supported replacement for the deprecated SQL Server Profiler), Query Store, and sys.dm_exec_requests / sys.dm_os_wait_stats to identify slow queries, missing indexes, and locking.
APM tracing: Application Insights or OpenTelemetry to correlate a slow user request with the exact SQL command and its duration.
Tool
Layer
What it reveals
PerfView / dotTrace
.NET runtime
CPU allocation, GC pressure, blocking time in ADO.NET calls
Extended Events / Query Store
SQL Server
Query duration, waits, missing indexes, deadlocks
Application Insights / OpenTelemetry
End-to-end
Which HTTP request maps to which SQL call and why it is slow
Exploit Connection Pooling
SqlConnection pools connections per unique connection string, process, and Windows identity by default. Keep the pool warm and stable:
Identical connection strings: any change (different user, pooling keyword) creates a new pool, which is expensive.
Open late, close early: wrap connections in using so they return to the pool deterministically; do not hold connections across long idle work.
Clear pools only when needed: SqlConnection.ClearPool / ClearAllPools are recovery tools for poisoned connections, not routine cleanup.
Use Async and Streaming
Call OpenAsync, ExecuteReaderAsync, and ReadAsync so threads are not blocked during I/O, and process rows as they arrive instead of buffering everything. DbDataReader implements IEnumerable, so you can stream rows into LINQ or an async iterator while the connection stays open. Avoid DataTable for large result sets: it buffers all rows and creates heavy per-row objects.
Reduce Round-Trips and Server Work
Batch instead of loop: one INSERT … VALUES (…),(…) or a table-valued parameter beats hundreds of single-row commands.
Set CommandTimeout explicitly (in seconds) for known-slow reports rather than raising the global default.
CommandBehavior.SequentialAccess for wide rows with large BLOB columns: it avoids buffering entire rows.
SqlBulkCopy for large inserts. By default it skips constraint checking and triggers, so pass SqlBulkCopyOptions.CheckConstraints or FireTriggers when those are needed. UseInternalTransaction commits per batch; for all-or-nothing semantics enlist the bulk copy in an explicit SqlTransaction.
MARS (MultipleActiveResultSets=true) can avoid the “open reader per connection” limit, but it is not a concurrency win; prefer multiple commands or async batching.
Code Example
using System.Data;
using Microsoft.Data.SqlClient;
// Tuned pattern: pooled connection, async I/O, streaming reader, explicit parameter type.
const string connectionString = "Server=.;Database=Shop;Integrated Security=true;TrustServerCertificate=false;Pooling=true;Max Pool Size=100";
string region = "North"; // example filter value; in real code this comes from validated input
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync(); // async open, returns to pool on dispose
await using var command = connection.CreateCommand();
command.CommandText = "SELECT Id, Name FROM Customers WHERE Region = @region";
command.CommandTimeout = 30; // explicit, in seconds
command.Parameters.Add("@region", SqlDbType.NVarChar, 50).Value = region;
await using var reader = await command.ExecuteReaderAsync(CommandBehavior.SequentialAccess);
while (await reader.ReadAsync()) // stream rows; no full buffering
{
var id = reader.GetInt32(0);
var name = reader.GetString(1);
// project directly into the DTO you actually need
}
Best Practices and Common Pitfalls
Never use AddWithValue: it infers types and causes implicit conversions, bad parameter sniffing, and index misses. Use Parameters.Add(name, SqlDbType) with Size, Precision, or Scale where relevant.
Send DBNull.Value, not C# null: a parameter whose Value is null is treated as not supplied.
Close readers before running a second command on the same connection unless MARS is enabled.
Watch for DataException masking: log the SQL text and duration alongside exceptions so slow queries surface in production.
Do not call AcceptChanges after DataAdapter.Update — Update already accepts changes per saved row.
Avoid DataSet binary serialization (RemotingFormat = Binary): it depends on BinaryFormatter, which is obsolete and removed in .NET 9; use a modern serializer or EF Core tracking instead.
69
What strategies can be employed to implement caching within an ADO.NET data access layer to improve responsiveness and reduce database load?
KnownRevise
Caching in ADO.NET primarily leverages the disconnected data architecture alongside modern in-memory caching providers to minimize database round-trips. By storing retrieved data locally in memory, applications reduce latency and database load, provided cache consistency is managed carefully.
ADO.NET natively supports caching through DataSet and DataTable objects. These structures hold data in memory after a DataAdapter fills them, allowing manipulation without an open connection. This is ideal for scenarios requiring multiple passes over data or batch updates. However, DataSet objects are heavy; for simple lookups, storing lightweight DTOs in a dedicated cache is preferable.
Modern In-Memory Caching
In .NET 8, wrapping ADO.NET calls with IMemoryCache is the recommended pattern for web applications. This interface provides sliding and absolute expiration policies. Unlike DataSet, which caches schema and data together, IMemoryCache stores object references to collections or DTOs directly in process memory, offering better control over memory footprint and expiration logic without serialization overhead.
Cache Invalidation Strategy
Cached data must expire when the underlying database changes. Use absolute expiration for static reference data (e.g., country lists) and sliding expiration for frequently accessed user data. For critical consistency, implement an invalidation pattern where the cached entry is updated or removed immediately after a successful database write operation.
Comparing Caching Approaches
Approach
Best For
Limitations
DataSet/DataTable
Offline scenarios, batch updates, multiple data passes
Heavy memory footprint, schema overhead
IMemoryCache
Single-server web apps, fast lookups
Not shared across servers, memory pressure
IDistributedCache (Redis)
Multi-server deployments, shared state
Network latency, serialization cost
Best Practices and Pitfalls
Avoid BinaryFormatter: Do not use DataSet.RemotingFormat = Binary for caching; it relies on obsolete BinaryFormatter. Use JSON serialization instead.
Parameter Safety: Always use explicit parameter types; never use AddWithValue, which can infer incorrect types and hinder plan reuse.
Memory Pressure: Monitor cache size. Large DataSet objects retained indefinitely can cause memory pressure in long-running processes.
Distributed Cache: For multi-server deployments, replace IMemoryCache with IDistributedCache (e.g., Redis) to ensure consistency across instances.
Cache Stampede: Use a locking or semaphore pattern when populating cache entries to prevent multiple simultaneous database hits for the same missing key.
70
What are typical ADO.NET coding mistakes that negatively impact application performance?
KnownRevise
Several recurring anti-patterns in ADO.NET data access significantly degrade throughput and scalability, primarily involving connection management, parameter handling, and data retrieval strategies. Addressing these issues ensures efficient use of SQL Server resources and prevents common bottlenecks like connection pool exhaustion or plan cache pollution.
Failing to close connections promptly is the most critical performance anti-pattern. Connections should be opened as late as possible and closed as early as possible. Relying on garbage collection to finalize SqlConnection objects exhausts the connection pool, causing timeouts under load. Always wrap connections, commands, and readers in using statements to guarantee disposal, even when exceptions occur. Connection pooling is enabled by default, so reusing connection strings allows the pool to recycle physical connections efficiently.
Parameter Type Inference
Using AddWithValue forces ADO.NET to infer the SQL parameter type from the .NET value, often resulting in implicit conversions on the server side. This prevents query plan reuse and can lead to index scans instead of seeks. Always declare the explicit SqlDbType when adding parameters. For string parameters, specify the Size to match the database column definition, preventing unnecessary memory allocation and ensuring plan cache stability. For example, use SqlDbType.VarChar with a specific length rather than letting the provider guess nvarchar(max).
Data Retrieval Volume
Fetching more data than necessary wastes network bandwidth and memory. Avoid SELECT * statements and retrieve only required columns. For large datasets, implement server-side paging using OFFSET FETCH rather than loading entire tables into a DataSet or DataTable. Additionally, prefer SqlDataReader for forward-only streaming over disconnected objects when processing large result sets sequentially, as it reduces memory overhead significantly.
Synchronous Blocking on Async
In web applications, calling .Result or .Wait() on async ADO.NET methods blocks the thread pool thread, reducing scalability and risking deadlocks in ASP.NET contexts. Use await consistently throughout the call stack. While synchronous APIs exist for console apps or specific legacy scenarios, async APIs (ExecuteReaderAsync, ExecuteScalarAsync) are preferred for I/O-bound database operations in modern .NET workloads to free up threads during network latency.
Best Practices and Pitfalls
Command Text Caching: Ensure SQL text is identical (including whitespace) to benefit from server-side plan caching.
Null Handling: Pass DBNull.Value instead of C# null for optional parameters to avoid “parameter not supplied” errors.
Transactions: Use SqlTransaction for atomic operations; distributed transactions require TransactionManager.ImplicitDistributedTransactions = true on .NET 7+ Windows and are unsupported on Linux.
Bulk Operations: For inserting large volumes, use SqlBulkCopy instead of row-by-row inserts, but remember it does not fire triggers by default.
Reader Usage: Call command.ExecuteReader(CommandBehavior.CloseConnection) to ensure the connection closes when the reader is disposed.
Avoid mixing System.Data.SqlClient with Microsoft.Data.SqlClient in the same project, as the latter is the actively developed package for .NET Core and .NET 5+.
71
What security practices should you follow when using ADO.NET to establish and manage database connections?
KnownRevise
The most critical security concern when using ADO.NET is preventing SQL injection by always using parameterized queries, combined with protecting connection strings and credentials from accidental exposure. Encryption in transit, least-privilege database accounts, and careful error handling complete the core defense strategy.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient
SQL Injection Prevention
Parameterized queries: Never build SQL by concatenating user input into command text. Parameters send the value separately from the SQL, so input cannot change the query structure.
Explicit parameter types: Add every parameter with an explicit SqlDbType and size, which also limits how much data an attacker can send.
Stored procedures: Calling stored procedures via CommandType.StoredProcedure is safe only when the procedure itself uses parameters internally; it does not automatically protect against injection.
Connection String and Credential Protection
Never hard-code secrets: Connection strings containing passwords must not appear in source code. Read them from environment variables, Azure Key Vault, or .NET user secrets (development only).
Prefer Windows Authentication: Where the architecture allows, Integrated Security=true (Windows Authentication) avoids storing SQL passwords entirely. This applies to Windows and to Linux clients joined to the domain with Kerberos.
Protect configuration files: In deployed applications, encrypt the <connectionStrings> section or rely on a secrets manager rather than plaintext config files.
Connection pooling consistency: The pool key is derived from the connection string; differing keywords or values create separate pools, which can exhaust server connections. Build connection strings from a single configured source rather than concatenating fragments.
Encryption in Transit
Enable encryption: Add Encrypt=True to the connection string so traffic between the application and SQL Server is encrypted with TLS.
Validate the server certificate: Set TrustServerCertificate=False in production so the client validates the server certificate chain. TrustServerCertificate=True should only be used in local development, as it disables validation and enables man-in-the-middle attacks.
Least Privilege and Authentication
Dedicated database accounts: Give the application its own login with only the permissions it needs (for example, SELECT and EXECUTE on specific objects), never sa or db_owner.
Short-lived credentials: When using cloud identity or managed identities, prefer token-based authentication that rotates automatically over long-lived passwords.
Error Handling and Logging
Do not expose database errors: Catch SqlException and log the details server-side, but return a generic message to the caller. Raw error text can reveal table names, column names, or server version.
Log safely: Never log connection strings, passwords, or parameter values that may contain sensitive data.
Common Pitfalls
Using AddWithValue: It infers the SQL type from the .NET value, which can cause implicit conversions, plan-cache pollution, and unexpected truncation. Always use Parameters.Add with an explicit SqlDbType and size.
Leaving TrustServerCertificate=True in production: This disables certificate validation.
Sending null for database NULL: A parameter whose Value is C# null is treated as not supplied; assign DBNull.Value when the database value is NULL.
Exposing stack traces: Returning SqlException.Message or the full exception to the browser leaks schema details.
Relying on System.Data.SqlClient: It is deprecated for new development; use Microsoft.Data.SqlClient instead.
72
How do you configure an ADO.NET connection to SQL Server so that it authenticates with the caller's Windows identity instead of a SQL Server login and password?
KnownRevise
Windows Authentication (also called integrated security) lets SqlConnection authenticate to SQL Server through the Security Support Provider Interface (SSPI) using the Windows identity of the process or thread that opens the connection, so no database password is stored in the connection string or the application.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and the `Microsoft.Data.SqlClient` NuGet package (SQL Server 2016+; the same connection-string keywords also work with `System.Data.SqlClient` in .NET Framework 4.8, which is legacy).
How It Works
When the connection string contains Integrated Security=true (or the older synonym Trusted_Connection=true), SqlConnection ignores any User ID / Password values and asks SSPI to negotiate authentication with SQL Server.
SQL Server maps the presented Windows account to a server principal (a Windows login, possibly a member of a Windows group), and permissions are granted to that principal — there is no SQL Server login or password to manage or leak.
The default is Integrated Security=false, which switches to SQL Server authentication and requires User ID and Password in the connection string.
Use Server=ServerName\InstanceName for a named instance and Server=ServerName,1433 to pin a TCP port; Encrypt=true and TrustServerCertificate=false should stay at their secure defaults so login traffic is encrypted and the server certificate is validated.
Because the integrated-security string contains no secret, it can safely live in appsettings.json; a SQL-authentication string must be protected with the .NET configuration/secret manager or a protected store.
Identity and Platform Considerations
Windows: the connection uses the Windows identity of the current thread. Services should run as a domain service account or Group Managed Service Account that has been granted access to the database; code that must connect as a different user should run the data access under an impersonated Windows identity from the System.Security.Principal APIs.
Linux and macOS: Microsoft.Data.SqlClient also supports Windows Authentication, but only when the machine is Kerberos-configured and the process has acquired a valid Kerberos ticket (kinit); without it the connection fails, so document this requirement for container deployments.
Distributed transactions: if integrated security is combined with System.Transactions escalation, note that distributed transactions are supported on .NET Framework and on .NET 7+ only on Windows after enabling TransactionManager.ImplicitDistributedTransactions, and are not supported on Linux/macOS.
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Configuration;
using System;
using System.Data;
// Build configuration from appsettings.json (Microsoft.Extensions.Configuration.Json package).
IConfiguration config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.Build();
// Read the connection string from configuration, never hard-code it.
string connectionString = config.GetConnectionString("AdventureWorks")
?? throw new InvalidOperationException("Missing connection string 'AdventureWorks'.");
using var connection = new SqlConnection(connectionString);
connection.Open(); // SSPI authenticates using the current Windows identity
using var command = new SqlCommand(
"SELECT FirstName, LastName FROM Person.Person WHERE BusinessEntityID = @id",
connection);
// Always bind parameters with an explicit type; never use AddWithValue.
command.Parameters.Add("@id", SqlDbType.Int).Value = 42;
using var reader = command.ExecuteReader();
while (reader.Read())
{
Console.WriteLine($"{reader.GetString(0)} {reader.GetString(1)}");
}
Best Practices and Common Pitfalls
Prefer Windows Authentication for on-premises SQL Server: it removes passwords from configuration, centralizes account management in Active Directory, and supports account lockout and auditing policies.
Grant least privilege: map a dedicated domain service account or group to a database role with only the permissions the application needs, rather than adding users to a broad role such as sysadmin.
Do not mix schemes by accident: Integrated Security=true combined with User Id/Password silently authenticates with Windows, which can mask a misconfiguration.
Do not fall back to SQL authentication for convenience: if a platform cannot obtain a Windows identity, fix the Kerberos setup or use a scoped SQL login with a secret from a secure store rather than embedding credentials in source control.
Test the exact runtime identity: the account that opens the connection is the one SQL Server sees, so verify with SELECT SUSER_NAME() when debugging permission errors.
73
Describe the recommended strategies for encrypting and managing sensitive configuration data, such as connection strings, in modern ADO.NET applications.
KnownRevise
In modern .NET, sensitive ADO.NET configuration data such as connection strings is managed with secret management providers — the User Secrets store in development, environment variables for containers, and a centralized vault such as Azure Key Vault in production — rather than by encrypting configuration files with legacy tools. Secrets are injected into the application through the IConfiguration pipeline, so credentials never live in source control or inside deployment artifacts.
Read full answerHide full answer
Applies to.NET 8 and C# 12 with `Microsoft.Data.SqlClient` (DPAPI encryption is Windows-only).
Modern Configuration Providers
.NET 8 uses the composable Microsoft.Extensions.Configuration model, where IConfiguration is built from an ordered chain of providers (JSON files, environment variables, command line, user secrets, vault providers and others). Because each provider overrides the values of the ones registered before it, a safe pattern is to keep non-sensitive defaults in appsettings.json and let a secret provider override the sensitive entries at runtime. Connection strings are then read from IConfiguration (for example configuration.GetConnectionString("Default")) and passed straight to SqlConnection; the application code is identical regardless of where the value came from.
Development: User Secrets
For local development, the recommended store is the User Secrets tool (Microsoft.Extensions.Configuration.UserSecrets NuGet package, enabled with dotnet user-secrets init). Secrets are stored in a JSON file under the user profile directory (secrets.json), outside the project folder, so they are never accidentally committed. The tooling also supports dotnet user-secrets set "ConnectionStrings:Default" "...". Team members each hold their own copy of the secrets, and the AddUserSecrets call is typically wired up in Program.cs through the host defaults, which include it automatically in the Development environment.
Windows-Only Encryption with DPAPI
When an encrypted local file is genuinely required on Windows, the ProtectedData class (namespace System.Security.Cryptography, shipped in the System.Security.Cryptography.ProtectedData NuGet package) exposes DPAPI through ProtectedData.Protect and ProtectedData.Unprotect, using DataProtectionScope.CurrentUser or DataProtectionScope.LocalMachine. DPAPI derives keys from the OS, so there is no key material to manage or store in the config file itself. It is supported only on Windows; on Linux and macOS the methods throw PlatformNotSupportedException, which makes DPAPI unsuitable for cross-platform or containerized deployments. Note that LocalMachine scope requires access to the machine’s DPAPI key material, which by default non-admin accounts do not have.
Production: Azure Key Vault
For cloud and enterprise deployments, Azure Key Vault is the recommended store, referenced from configuration through the Azure.Extensions.AspNetCore.Configuration.Secrets package. This centralizes secret lifecycle, enables rotation without redeploying the application, and provides access and audit logging. The application authenticates with a Managed Identity (via Azure.Identity), so no client secret or password is embedded in the code or configuration. Equivalent managed-vault providers exist for AWS (Secrets Manager) and other clouds, and the same IConfiguration integration pattern applies.
Best Practices and Pitfalls
Never hardcode: Connection strings with credentials must not appear in source control, Docker images, or CI logs.
Choose the right scope: Use DataProtectionScope.CurrentUser for interactive user applications and LocalMachine only for Windows services running under an account with access to the machine key material.
Keep secrets short-lived in memory: Read the connection string when the SqlConnection is created and avoid caching decrypted values in long-lived static fields.
Prefer vaults over file encryption: DPAPI-encrypted config files tie secrets to a specific machine and account, which complicates scaling and deployment; vaults and environment variables travel with the deployment pipeline.
Expect legacy questions in interviews:aspnet_regiis.exe and the encrypted-<connectionStrings> model belong to .NET Framework and are not available in .NET 8; say so explicitly rather than calling them merely deprecated.
Validate and never log: Treat a decrypted connection string as untrusted input and let SqlConnectionStringBuilder validate it before use; never write secrets to logs, telemetry, or error messages.
74
Can you explain what SQL injection is and describe the specific features within ADO.NET that developers should use to prevent it?
KnownRevise
SQL injection is a malicious technique where attackers manipulate database queries by inserting unauthorized SQL code through user inputs. ADO.NET mitigates this risk effectively by utilizing parameterized queries, which ensure that user input is always treated as data values rather than executable commands.
SQL injection occurs when application code concatenates user input directly into SQL strings. For example, building a query like "SELECT * FROM Users WHERE Name = '" + input + "'" allows an input such as '; DROP TABLE Users; -- to alter the command structure. This can lead to unauthorized data access, data loss, or authentication bypass. The root cause is trusting input as part of the command syntax rather than as literal data.
How Parameterized Queries Protect You
Parameterized queries use placeholders (for example, @userId) in the SQL text. When executed, the ADO.NET provider sends the command structure and the parameter values to the database separately. The database engine treats the values strictly as literals and enforces their data types, so special characters in the input cannot alter the command structure or execution plan. This separation of code and data is the single most effective defense against SQL injection in ADO.NET.
Stored procedures also provide this protection when they are called with parameters rather than building dynamic SQL inside the procedure. A stored procedure that concatenates user input into an EXEC or sp_executesql statement remains vulnerable, because the dynamic SQL string is still assembled from untrusted text.
Safe vs Unsafe Data Access
Approach
Example
Risk
String concatenation
"SELECT * FROM Users WHERE Id = " + id
High: input becomes part of the SQL command text
Parameterized query
SELECT * FROM Users WHERE Id = @id with Parameters.Add("@id", SqlDbType.Int)
None: input is bound as a value, never parsed as SQL
Dynamic SQL inside a procedure
EXEC('SELECT * FROM Users WHERE Id = ' + @id)
High: same vulnerability as concatenation
Parameterized procedure call
EXEC GetUser @Id with parameters
None: input is bound as a value
Common Pitfalls and Best Practices
Avoid AddWithValue: This method infers types from C# values, which can cause performance issues (plan cache pollution) or type mismatches. Always specify SqlDbType explicitly, for example command.Parameters.Add("@id", SqlDbType.Int).Value = id.
Never concatenate input: Parameters cannot be used for object names such as table or column names, so whitelist allowed identifiers instead of concatenating them.
Least privilege: Database accounts used by the application should have only the necessary permissions (for example, SELECT, INSERT), not DROP or ALTER.
Validate input: Parameterization prevents injection, but validating input (for example, ensuring an ID is positive) adds defense in depth.
Handle nulls correctly: Pass DBNull.Value instead of C# null for optional database columns to avoid unintended errors.
Use the current provider: Use Microsoft.Data.SqlClient for .NET 8, not the legacy System.Data.SqlClient, which is no longer actively developed. Higher-level data access such as EF Core 8 also uses parameterization internally, but the same rules apply when writing raw SQL.
75
In what ways can ADO.NET be combined with other parts of the .NET ecosystem?
KnownRevise
ADO.NET integrates with the rest of .NET because its core types live in System.Data and Microsoft.Data.SqlClient and are designed to interoperate with LINQ, XML serialization, transactions, configuration, and dependency injection. The main bridges are DataTable/DataSet for LINQ to DataSet and XML, DbDataReader (which implements IEnumerable) for streaming LINQ queries, DbDataAdapter for offline DataTable editing, and TransactionScope from System.Transactions for ambient transactions.
Read full answerHide full answer
Applies to.NET 8 and C# 12 with Microsoft.Data.SqlClient (the supported successor to the legacy `System.Data.SqlClient`)
How the Integration Works
ADO.NET is not an island: its classes are plain managed types that other .NET stacks consume directly.
LINQ integration: DataTable exposes a Rows collection of type DataRowCollection, and the AsEnumerable() extension (from System.Data.DataSetExtensions) exposes each row as DataRow, so LINQ queries work over in-memory results. DbDataReader implements IEnumerable, so reader.Cast<IDataRecord>() lets LINQ process rows while they are still streaming from the server.
XML integration: DataSet and DataTable have built-in ReadXml/WriteXml methods. XmlWriteMode has only WriteSchema, IgnoreSchema, and DiffGram; DiffGram is the format used when the DataSet is serialized for transport or compared before and after edits.
Transactions: an SqlConnection enlists automatically in an ambient System.Transactions.TransactionScope. Distributed transactions are supported on .NET Framework, and on .NET 7+ only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; they are not supported on Linux or macOS.
EF Core 8: EF Core’s SQL Server provider is built on top of ADO.NET (Microsoft.Data.SqlClient), so every EF Core query ultimately flows through SqlConnection, SqlCommand, and DbDataReader. You can also drop down to ADO.NET from a DbContext via Database.GetDbConnection().
Configuration and DI: connection strings are read with IConfiguration.GetConnectionString(name). SqlConnection is not registered by the DI container by default; register a factory (e.g. Func<DbConnection>) or a DbConnection with an explicit lifetime (transient or scoped, matching your unit of work).
Key Integration Points
Component
ADO.NET type used
Typical use
LINQ to DataSet
DataTable.AsEnumerable()
Querying filled tables without re-hitting the database
LINQ to Objects
DbDataReader (IEnumerable)
Streaming large result sets with lazy evaluation
XML
DataSet.WriteXml / ReadXml
Persisting offline data, schema included
System.Transactions
SqlConnection + TransactionScope
Coordinating multiple commands or connections atomically
SqlBulkCopy
DataTable or DbDataReader
Loading a whole table or query result at once
EF Core 8
Microsoft.Data.SqlClient provider
Underlying data-access layer for EF
Best Practices and Common Pitfalls
Prefer Microsoft.Data.SqlClient over System.Data.SqlClient; the latter is legacy and receives only critical fixes.
Never use AddWithValue: it infers types from the value and causes plan-cache pollution and truncation bugs. Use Parameters.Add(name, type) and set Size, Precision, or Scale where the type needs them.
Send DBNull.Value for a database NULL: a parameter whose Value is C# null is treated as not supplied, which throws at runtime.
Use Field<int?>() for nullable columns: the non-nullable Field<T>() throws on DBNull.
Do not call AcceptChanges after DataAdapter.Update: Update already calls it on each saved row.
SqlBulkCopy skips constraints and triggers by default; pass SqlBulkCopyOptions.CheckConstraints or FireTriggers when you need them, and use an external SqlTransaction rather than UseInternalTransaction if you need all-or-nothing behavior.
Avoid DataSet binary serialization (RemotingFormat = Binary): it depends on BinaryFormatter, which is obsolete and removed in .NET 9; use XML or a modern serializer instead.
76
Is ADO.NET capable of communicating with RESTful web services, and what techniques are used to accomplish this?
KnownRevise
ADO.NET itself does not include an HTTP client, but its in-memory data structures—DataSet, DataTable, and DbDataReader—can exchange data with RESTful services by serializing to XML or JSON, while the actual network calls are performed with HttpClient from System.Net.Http. In practice, ADO.NET supplies the data containers and serialization helpers, and HttpClient handles the REST transport.
Read full answerHide full answer
Applies to.NET 8 with ADO.NET (System.Data) and System.Net.Http
How the Layers Connect
HttpClient: Sends GET, POST, PUT and DELETE requests to REST endpoints and reads responses. This is the officially recommended HTTP API in .NET 8; older types like HttpWebRequest and WebClient are obsolete (SYSLIB0014) and replaced by HttpClient.
ADO.NET data containers: DataSet and DataTable hold tabular data that can be filled from a REST response or sent as a REST payload.
Serialization bridge: ADO.NET provides DataSet.GetXml(), DataSet.ReadXml(), WriteXml() and WriteXmlSchema() to convert between tabular data and XML. There are no built-in JSON members on DataSet or DataTable; JSON is handled with System.Text.Json and DTO classes.
Serialization Format Comparison
Format
ADO.NET support
Typical use
XML
GetXml(), ReadXml(Stream/TextReader/string), WriteXml() built in
Legacy XML-first REST APIs or SOAP-style services
JSON
No built-in DataSet/DataTable JSON API; use System.Text.Json with DTOs
Modern REST APIs
DTO classes
Manual mapping or DataTable.Load from a reader
Strongly typed, validation-friendly code
Techniques for REST Integration
XML round-trip: Call DataSet.ReadXml() on the response body to populate tables, and DataSet.GetXml() to build a request payload. This avoids manual mapping but produces verbose XML.
JSON with DTOs: Deserialize the JSON response into record/DTO types with System.Text.Json, then copy values into a DataTable or work with the objects directly. This is the recommended approach for modern APIs.
Streaming reader: For large responses, parse JSON incrementally with Utf8JsonReader and fill a DataTable row by row instead of buffering the whole payload.
Best Practices and Common Pitfalls
Prefer HttpClientFactory: Register typed clients with IHttpClientFactory instead of creating HttpClient instances manually, to avoid socket exhaustion.
Use DTOs for JSON: System.Text.Json does not serialize DataTable into a clean JSON array by default; map to record/DTO types for predictable payloads.
Validate before loading: A REST response can contain fewer or extra columns than your DataTable expects; validate the shape before calling Rows.Add or ReadXml.
Handle nulls explicitly: GetFromJsonAsync returns null on a 404 or 204 response rather than throwing; check for null before iterating.
Secure transport: Always use HTTPS URLs, never embed secrets in code, and pass authentication tokens via headers (e.g. Authorization: Bearer) from a secure store.
Know the limitation: ADO.NET gives you data shaping and serialization, but it provides no REST client, no automatic change tracking across HTTP round trips, and no built-in retry or resilience—use Polly or HttpClient middleware for those.
77
In what ways can ADO.NET be consumed from or exchange data with languages and platforms outside the .NET ecosystem?
KnownRevise
ADO.NET is a managed, .NET-only data access API, so it is not directly callable from non-.NET languages; interoperability is achieved indirectly, through standards-based data exchange (XML and JSON), Windows COM interop, and provider bridges such as OLE DB and ODBC, which let ADO.NET talk to data sources built for other technology stacks.
Read full answerHide full answer
Applies toADO.NET on .NET 8; COM interop and OLE DB statements apply to .NET Framework 4.8 and to .NET on Windows only. `System.Data.Odbc` is cross-platform on .NET 8.
How Interoperability Works
ADO.NET consists of managed classes (DbConnection, DbCommand, DbDataReader, DataSet, DataTable, DbProviderFactory) running inside the CLR. Because non-.NET languages cannot resolve CLR metadata or use the managed object model directly, ADO.NET exposes its data through serialization and interop layers rather than through a shared native API. The two directions are distinct:
Consuming ADO.NET data from non-.NET code: the data is serialized to a portable format (XML, JSON) and transferred over a service boundary, or the ADO.NET objects are exposed to COM clients on Windows.
Consuming non-.NET data sources from ADO.NET: provider bridges (OLE DB, ODBC) and third-party managed providers let ADO.NET talk to databases and APIs authored for other stacks.
Key Interoperability Mechanisms
XML serialization of the DataSet: DataSet.WriteXml and DataSet.ReadXml, together with WriteXmlSchema, persist relational data and its schema as plain XML. Any language with an XML parser (Java, Python, PHP, JavaScript, C++) can read the data without any .NET runtime. XmlWriteMode.DiffGram preserves change information (original/current values, inserts, updates, deletes), which makes round-tripping possible across heterogeneous systems.
JSON and HTTP services: in modern applications the usual pattern is to read data with a DbDataReader or DataTable in an ASP.NET Core service and return JSON or XML over HTTP. The non-.NET client never sees ADO.NET at all; it only consumes the serialized payload. (Legacy WCF services can also expose data, but WCF is Windows-only and not recommended for new cross-platform work.)
COM interop (Windows only): on .NET Framework, ADO.NET types can be exposed to COM clients such as VB6, classic ASP, or Visual C++ through COM-callable wrappers generated with regasm/tlbexp, provided the classes are attributed with ComVisible(true). This is legacy: the ADO.NET object model is not designed for COM consumption, and the COM-based predecessor, classic ADO (ADODB.Recordset), is the natural choice for those clients. COM interop is not supported on Linux or macOS.
OLE DB and ODBC bridges: System.Data.OleDb wraps COM-based OLE DB providers and is Windows-only. System.Data.Odbc wraps the ODBC driver manager and is cross-platform (Windows, Linux, macOS), but on non-Windows it requires a native driver manager (e.g., unixODBC) and appropriate drivers. Both let ADO.NET reach data sources written for the Win32/COM ecosystem.
Third-party managed providers: products such as Oracle.ManagedDataAccess.Core (ODP.NET), MySqlConnector, and Npgsql implement the ADO.NET provider abstraction for non-Microsoft databases, so .NET applications access those stores through the same DbConnection/DbCommand model.
CLS compliance for other .NET languages: within .NET itself, ADO.NET’s public API follows Common Language Specification conventions, so any CLS-compliant .NET language (F#, Visual Basic, IronPython’s CLR hosting) can consume it natively — this covers “non-C#” but not “non-.NET”.
Mechanism
Enables
Platform availability
DataSet XML (WriteXml, DiffGram)
Data exchange with any XML-capable language
All platforms
JSON over HTTP (ASP.NET Core)
Any client language consuming web data
All platforms
COM interop
VB6, classic ASP, C++ clients on Windows
Windows only, legacy
OLE DB provider
Access to COM/Win32-era data sources
Windows only
ODBC provider
Access to ODBC data sources
Windows, Linux, macOS (driver required)
Third-party providers
Non-SQL Server databases from .NET
Per provider
Advantages and Limitations
The strength of this design is loose coupling: XML and JSON exchange carries only data and schema, so no .NET runtime is needed on the receiving side, and the choice of client language is unconstrained. The limitation is that nothing outside .NET gets the real ADO.NET programming model — connection pooling, DbDataReader streaming, command building, or typed DataSet code — only serialized snapshots or a service API. COM interop can expose objects but is Windows-specific, fragile across versions, and not recommended for new development.
Best Practices
Prefer a service boundary returning JSON or XML over exposing ADO.NET objects directly to other platforms.
Use XmlWriteMode.DiffGram only when the consumer needs change tracking; plain XML is simpler for read-only exchange.
Treat COM interop and classic ADO interop as legacy integration paths; document them as such and plan migration away from them.
Use Microsoft.Data.SqlClient for SQL Server on .NET 8; use System.Data.OleDb only for Windows OLE DB sources and note its platform restriction.
Keep DataTable/DataSet XML payloads small and version the schema explicitly, since the implicit schema can drift between .NET versions.
78
What key factors must be addressed when deploying a solution that uses ADO.NET for database connectivity?
KnownRevise
Deploying an ADO.NET application primarily revolves around secure connection configuration and ensuring the correct native database client libraries are present on the target runtime. Mismanagement here leads to security vulnerabilities or runtime failures due to missing native dependencies, so infrastructure setup must align with the data access layer requirements.
Connection strings contain credentials and must never be hardcoded in source control. Use .NET User Secrets during development and Azure Key Vault or environment variables in production. The configuration system should resolve the string at runtime, ensuring secrets are not embedded in the deployed binary. Always validate the connection string format before attempting to open a connection to prevent injection attacks via configuration files.
Native Dependency Management
Microsoft.Data.SqlClient 2.0 and later uses different native stacks per platform. On Windows it relies on the native SNI library (SNI.dll). On Linux and macOS it uses a managed networking stack, so no ODBC driver (such as libmsodbcsql) needs to be installed; the runtime does require OpenSSL libraries, which are normally present on the target distribution. When publishing a framework-dependent application, verify the target machine has the required native libraries for its OS. For self-contained deployments, publish with a specific Runtime Identifier (RID) such as win-x64 or linux-x64 to bundle the correct native assets automatically. Mismatched RIDs often result in DllNotFoundException errors during startup.
Connection Pooling Behavior
Connection pooling is enabled by default and significantly impacts deployment scalability. The pool is keyed by the exact connection string, so dynamic string generation fragments the pool. Tune Min Pool Size and Max Pool Size based on expected load and database limits. Ensure all SqlConnection objects are wrapped in using statements to return connections to the pool promptly; leaks exhaust the pool and cause timeout exceptions under load.
Platform-Specific Limitations
Behavior differs between Windows and Linux environments. Distributed transactions via System.Transactions are supported on .NET Framework and .NET 7+ Windows only, requiring TransactionManager.ImplicitDistributedTransactions = true. They are unsupported on Linux/macOS. Authentication methods also vary; Integrated Security works seamlessly on Windows domains but requires specific configuration or managed identities on Linux. Verify authentication mechanisms match the host OS capabilities before deployment.
Common Pitfalls
Avoid using System.Data.SqlClient as it is legacy; always use Microsoft.Data.SqlClient for .NET Core and later. Do not rely on AddWithValue for parameters, as it infers types incorrectly and harms plan cache performance. Remember that DataRow.Field<int>() throws on DBNull; use nullable types when schema permits. Finally, never enable RemotingFormat = Binary for DataSet serialization, as it depends on BinaryFormatter, which is obsolete and removed in .NET 9.
79
What strategies ensure robust database connection lifecycle control and consistent schema versioning when deploying applications?
KnownRevise
Effective database interaction relies on connection pooling managed automatically by ADO.NET and explicit schema versioning tracked through a dedicated table or migration tool. Connections should be opened late and closed early within using blocks, while versioning ensures deployment scripts apply changes idempotently without data loss.
ADO.NET enables connection pooling by default, which reuses physical connections to reduce overhead. Developers must not hold connections open longer than necessary; instead, wrap SqlConnection in using statements (or await using for async) to guarantee disposal. When the connection is closed, it returns to the pool rather than terminating physically. Use async methods like OpenAsync and ExecuteScalarAsync to prevent thread pool starvation in web applications. Connection strings should reside in secure configuration stores like Azure Key Vault or User Secrets, never hardcoded.
Schema Versioning Implementation
ADO.NET itself does not provide built-in schema versioning; this must be implemented manually or via EF Core Migrations. A common pattern involves a SchemaVersion table storing the current database revision. During startup or deployment, the application reads this value and compares it against the expected version. If mismatches occur, deployment pipelines execute migration scripts. For pure ADO.NET projects, tools like DbUp or Flyway manage script execution, while EF Core 8 integrates migrations directly if the ORM layer is used.
Deployment Safety and Idempotency
Migration scripts must be idempotent, meaning running them multiple times yields the same state without errors. Wrap schema changes in explicit transactions to ensure all-or-nothing execution; if one step fails, the transaction rolls back. Always backup production databases before applying migrations. Avoid locking tables for extended periods during peak traffic. Use SET XACT_ABORT ON in SQL scripts to ensure implicit transactions roll back on errors.
Common Pitfalls
AddWithValue: Avoid Parameters.AddWithValue as it infers types incorrectly, often causing index scans instead of seeks.
Long-lived Connections: Keeping connections open across requests exhausts the pool and causes timeouts.
Hardcoded Secrets: Storing passwords in source code risks exposure; use environment variables or secret managers.
Ignoring DBNull: Failing to check for DBNull.Value when reading nullable columns causes InvalidCastException.
Skipping Backups: Applying schema changes without a restore point risks permanent data loss during failed deployments.
80
Explain how ADO.NET data access is structured and used across the layers of a multi-tier application.
KnownRevise
In a multi-tier architecture, ADO.NET lives almost exclusively in the data access layer (DAL), where its connected objects (SqlConnection, SqlCommand, SqlDataReader) and disconnected objects (DataSet, DataTable) are wrapped behind interfaces so the business and presentation layers never see a database type.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient (the successor to the legacy `System.Data.SqlClient`, which remains available for .NET Framework 4.x maintenance work).
Where ADO.NET Fits in the Layers
A typical n-tier design splits responsibilities as follows:
Presentation layer: ASP.NET Core MVC/Razor Pages, Blazor, or .NET MAUI desktop and mobile clients. It renders data and collects input; it contains no SQL or ADO.NET types.
Business logic layer (BLL): enforces validation, workflow and security rules. It calls the DAL through interfaces and works with plain DTOs or, in classic designs, DataSet/DataTable results.
Data access layer: the only place Microsoft.Data.SqlClient appears. It opens connections, executes commands, maps results to DTOs, and returns them to the BLL.
Database tier: SQL Server or another provider, accessed only through the DAL.
Because tiers may be separated by a process or network boundary, keep calls coarse-grained: one round trip that returns an aggregate is cheaper than several chatty calls.
Data Access Layer Design
Repository and Unit of Work patterns: define IRepository<T> and an abstraction over the connection/transaction so the BLL can be unit-tested with fakes. The DAL implementation owns the real SqlConnection.
Connected vs disconnected model: use SqlDataReader for read-only streaming of large result sets; use DataSet/DataTable when you genuinely need in-memory navigation, filtering, or batched updates through SqlDataAdapter. Most modern n-tier apps prefer the reader plus DTO mapping, since DataSet instances are heavy and leak schema details upward.
Async by default: use OpenAsync, ExecuteReaderAsync and ReadAsync so the web server thread is released while the database works.
Connection pooling: the SqlClient provider pools connections automatically when the connection string is identical; never disable pooling without a measured reason, and always dispose connections so they return to the pool.
Passing Data Between Tiers
Approach
Strengths
Drawbacks
DTOs serialized as JSON
Lightweight, contract-driven, ideal for APIs and clients of any platform
Requires explicit mapping code
DataSet/DataTable
Built-in change tracking, GetChanges, and Merge for occasional-connection scenarios
Carries schema and provider coupling; binary serialization relies on BinaryFormatter, which is obsolete and removed in .NET 9
SqlDataReader
Fastest possible streaming read
Tied to an open connection; must not cross a tier boundary
For cross-process calls, serialize DTOs as JSON. DataTable can still be written with WriteXml when an offline snapshot is genuinely required, but treat it as a legacy choice.
Transactions, Connections, and State
Keep a transaction inside the DAL: open the connection, begin a SqlTransaction, enlist every command, and commit or roll back in a single method.
Distributed transactions are supported on .NET Framework, and on .NET 7+ only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; they are not supported on Linux or macOS. Prefer a single local transaction or a saga-style compensating design.
Store connection strings in appsettings.json, user secrets, or Azure Key Vault — never in source or hard-coded strings — and read them through IConfiguration.
Best Practices and Common Pitfalls
Parameterize every query with an explicit type, e.g. command.Parameters.Add("@id", SqlDbType.Int).Value = id (adding Size, Precision and Scale where the type needs them). Never use AddWithValue, which infers types and causes plan-cache and truncation problems.
Send DBNull.Value for a database NULL; a parameter whose Value is C# null is treated as not supplied.
Wrap SqlConnection, SqlCommand, SqlDataReader and SqlTransaction in using blocks so resources are released even on exceptions.
Use one active reader per connection; open a second connection if you must read while reading.
Map nullable columns with DataRow.Field<int?>() — Field<int>() throws on DBNull.
Do not call AcceptChanges after SqlDataAdapter.Update; Update already calls it for each saved row.
Remember that SqlBulkCopy skips constraint checks and triggers by default, and that UseInternalTransaction commits per batch, so all-or-nothing loads need an external SqlTransaction.
81
How does ADO.NET coordinate transactions that span multiple resource managers, and how do you implement them?
KnownRevise
A distributed transaction in ADO.NET is a single atomic operation that updates two or more independent resource managers — for example, two different SQL Server instances, or SQL Server plus another durable resource — and is committed or rolled back as one unit through a two-phase commit protocol coordinated by the Microsoft Distributed Transaction Coordinator (MS DTC). Your code drives it through the System.Transactions infrastructure, most commonly with TransactionScope, and the .NET data providers enlist their connections into that ambient transaction automatically.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and `Microsoft.Data.SqlClient`; distributed transactions are supported on .NET Framework, and on .NET 7+ only on Windows after opting in — they are not supported on Linux or macOS.
How It Works
Ambient transaction: A TransactionScope creates a transaction object and stores it in thread-local storage (Transaction.Current). Any DbConnection opened inside the scope detects the ambient transaction and enlists itself, provided the provider supports it (SqlConnection does, and the connection string does not set Enlist=false).
Two-phase commit: When the scope is completed, the coordinator asks every enlisted resource manager to prepare (phase one). Only if all vote “can commit” does it send the final commit (phase two). If any participant fails to prepare, or the scope is disposed without Complete(), the coordinator instructs all participants to roll back.
Resource manager role: SQL Server itself acts as a resource manager and registers with MS DTC during enlistment, so the database engine handles its part of the protocol; your code only starts and completes the scope.
When a Transaction Becomes Distributed
A transaction is promoted to a distributed one when more than one durable resource is involved:
Opening a second connection to a different SQL Server instance (or a different database on an instance that does not share the transaction) inside the same scope.
Enlisting a connection to a different provider or resource manager in the same scope.
Some providers and older frameworks escalate when multiple connections are opened even against the same store; SQL Server keeps a single-connection transaction as a lightweight local transaction through its promotable single-phase optimization, which is why reusing one connection avoids MS DTC entirely.
Enlistment Models
Aspect
TransactionScope (implicit)
EnlistTransaction (explicit)
Who enlists
Provider, when the connection is opened
Your code, on an already open connection
Typical use
Business logic spanning several resources
Legacy code, connections opened before the transaction exists
Escalation behavior
Automatic when a second durable resource appears
Automatic, same rules
Failure mode
Forgetting Complete() silently rolls back
Enlisting a connection already in another transaction throws
Explicit enlistment looks like connection.EnlistTransaction(Transaction.Current), which enlists an already-open connection into the ambient System.Transactions.Transaction — useful for legacy code where the connection was opened before the scope existed.
Platform and Version Support
.NET Framework: full distributed transaction support, no opt-in required.
.NET 7+ / .NET 8: supported only on Windows, and only after setting TransactionManager.ImplicitDistributedTransactions = true early in the application. Without it, opening a second durable resource inside a scope throws instead of escalating.
Linux and macOS: distributed transactions are not supported; a scope that would escalate fails at runtime. Design around this with a single database, a resource-manager pattern such as the outbox pattern, or a saga with compensating actions.
Code Example
using System.Data;
using System.Transactions;
using Microsoft.Data.SqlClient;
// .NET 7+ on Windows only: allow a TransactionScope to escalate to MS DTC.
// On .NET Framework this property defaults to true, so the line is unnecessary.
// On Linux/macOS the setting has no effect; escalation to a distributed
// transaction still fails at runtime when a second durable resource is opened.
TransactionManager.ImplicitDistributedTransactions = true;
const string salesCs = "Server=sql-sales;Database=Sales;Integrated Security=true;Encrypt=true";
const string billingCs = "Server=sql-billing;Database=Billing;Integrated Security=true;Encrypt=true";
using (var scope = new TransactionScope(
TransactionScopeOption.Required,
new TransactionOptions
{
IsolationLevel = System.Transactions.IsolationLevel.ReadCommitted,
Timeout = TimeSpan.FromSeconds(30)
}))
{
using (var sales = new SqlConnection(salesCs))
{
sales.Open(); // auto-enlists in the ambient transaction
using var debit = sales.CreateCommand();
debit.CommandText = "UPDATE Accounts SET Balance = Balance - @amount WHERE AccountId = @id";
debit.Parameters.Add("@amount", SqlDbType.Money).Value = 100m;
debit.Parameters.Add("@id", SqlDbType.Int).Value = 42;
debit.ExecuteNonQuery();
}
using (var billing = new SqlConnection(billingCs))
{
billing.Open(); // second durable resource -> escalation to a distributed transaction
using var credit = billing.CreateCommand();
credit.CommandText = "UPDATE Ledger SET Total = Total + @amount WHERE LedgerId = @id";
credit.Parameters.Add("@amount", SqlDbType.Money).Value = 100m;
credit.Parameters.Add("@id", SqlDbType.Int).Value = 7;
credit.ExecuteNonQuery();
}
scope.Complete(); // drives the two-phase commit; omitting this line rolls everything back
}
Best Practices and Pitfalls
Always wrap TransactionScope in a using and call scope.Complete() as the last statement inside the block; an exception or a missing Complete() disposes the scope and rolls back.
Keep the scope short: MS DTC holds locks and durable enlistments for the whole duration, so long-running scopes are a classic cause of blocking and deadlocks across instances.
Prefer a single connection where possible: one connection to one database stays a cheap local transaction and avoids MS DTC entirely, which is usually an order of magnitude faster.
Choose the isolation level deliberately: Serializable (the historical default for TransactionScope) is rarely needed and increases contention; ReadCommitted is a better starting point.
Do not mix TransactionScope with manual BeginTransaction calls on the same connection unless you explicitly enlist, and never leave a scope open across an async boundary without using TransactionScopeAsyncFlowOption.Enabled, because the ambient transaction does not flow to other threads by default.
For cloud or cross-service workloads, prefer the outbox pattern or a saga with compensating transactions over MS DTC, since distributed transactions couple services tightly and are unavailable outside Windows on modern .NET.
82
Explain what Object-Relational Mapping (ORM) means within the ADO.NET ecosystem.
KnownRevise
Object-Relational Mapping (ORM) is a technique that automatically translates data between relational database tables and object-oriented classes, removing the need to write manual conversion code. Within the ADO.NET ecosystem, ADO.NET itself is not an ORM but the foundational data access layer, while libraries like Entity Framework Core 8 sit on top of providers such as Microsoft.Data.SqlClient to handle the mapping, SQL generation, and change tracking for you.
Read full answerHide full answer
Applies to.NET 8 with EF Core 8 and Microsoft.Data.SqlClient
The Impedance Mismatch
The core problem an ORM solves is the “impedance mismatch” between how databases store data and how applications model it.
Relational storage: Data lives in tables, rows, and columns, linked by foreign keys and joined at query time.
Object graphs: Data lives in instances, properties, and references, often navigated directly in memory.
Type differences: Database types like VARCHAR or DATETIME2 do not map one-to-one with C# types like string or DateTime without conversion logic.
An ORM maintains a metadata model that describes how a class corresponds to a table, allowing you to query and save objects without writing raw SQL for every operation.
ADO.NET vs ORM
It is a common interview misconception that ADO.NET is an ORM. It is not; it is the lower-level plumbing that ORMs often use internally.
Feature
ADO.NET
ORM (e.g., EF Core 8)
Abstraction level
Low-level connections and commands
High-level context and entities
SQL handling
Developer writes raw SQL or stored procedures
Generated automatically from LINQ
Mapping
Manual mapping from DbDataReader to objects
Automatic materialization of entities
Change tracking
None; developer manages state
Built-in unit of work and identity map
Primary classes
SqlConnection, SqlCommand, SqlDataReader
DbContext, DbSet<T>
Full ORM vs Micro-ORM
Not all mapping tools provide the same level of automation in the .NET world.
Full ORM: Entity Framework Core 8 is the mainstream choice. It provides LINQ translation, change tracking, migrations, and relationship fix-up. It is ideal for complex domain models where developer productivity matters more than raw query speed.
Micro-ORM: Dapper is a lightweight alternative that extends IDbConnection. It executes raw SQL and maps results to objects quickly but does not track changes or generate SQL. It is often used alongside ADO.NET for performance-critical read paths.
Best Practices and Pitfalls
Using an ORM introduces specific risks that candidates should be ready to discuss.
N+1 Query Problem: Navigating relationships in a loop can trigger a new database query per iteration. Use eager loading (Include) or explicit projection to avoid this.
Tracking Overhead: Change tracking consumes memory and CPU. Use AsNoTracking() for read-only queries in EF Core to improve performance.
Bulk Operations: ORMs are inefficient for bulk inserts or updates. For large data loads, drop down to ADO.NET and use SqlBulkCopy from Microsoft.Data.SqlClient, noting that it does not fire triggers or check constraints by default.
Leaky Abstractions: Complex queries may generate inefficient SQL. Always review the generated SQL during performance tuning rather than assuming the ORM chose the optimal plan.
83
What role does ADO.NET play when designing a data access strategy around an object-relational mapper?
KnownRevise
ADO.NET acts as both the foundational execution layer beneath an ORM and the controlled escape hatch for operations the mapper handles poorly. In a typical .NET 8 strategy, EF Core manages entity tracking and routine queries, while you drop down to raw ADO.NET (usually via Microsoft.Data.SqlClient) for bulk operations, complex reporting, or database-specific features that LINQ cannot express.
Read full answerHide full answer
Applies to.NET 8 and EF Core 8
The Layered Relationship
EF Core does not talk to the database directly; it translates LINQ queries and change-tracking actions into SQL, then executes them through ADO.NET providers. For SQL Server, this provider is Microsoft.Data.SqlClient, which replaced the deprecated System.Data.SqlClient. This means every ORM operation is already an ADO.NET operation under the hood. You only write explicit ADO.NET code when you need to bypass the ORM’s abstraction to gain performance or access features it does not support.
When to Use Raw ADO.NET Instead of the ORM
Scenario
Recommended Approach
Reason
Routine CRUD
EF Core
Change tracking and safety reduce boilerplate.
Bulk inserts
SqlBulkCopy or EF Core 8 ExecuteInsert
ORM individual inserts are too slow for large sets.
Complex reports
ADO.NET or Dapper
Read-only projections avoid tracking overhead.
Database-specific SQL
FromSqlRaw or ADO.NET
LINQ may not translate vendor-specific functions.
High-throughput reads
ADO.NET or Dapper
No change-tracking or identity map cost.
Integration Patterns
Shared connections: You can access the underlying DbConnection via Database.GetDbConnection() to run ADO.NET commands within the same session as the EF Core context.
Shared transactions: To keep data consistent, enlist your ADO.NET commands in the transaction started by the ORM context rather than opening a separate one.
Micro-ORMs: Libraries like Dapper sit between the two, using ADO.NET directly for fast mapping without full change tracking, and are often used alongside EF Core for read models.
Best Practices and Pitfalls
Parameter safety: When writing raw SQL, always add parameters with an explicit type, such as command.Parameters.Add("@id", SqlDbType.Int), and never use AddWithValue, which can cause plan cache pollution and type conversion errors.
NULL handling: Assign DBNull.Value for database NULLs; a C# null value on a parameter is treated as the parameter not being supplied.
Tracking boundaries: Data read via ADO.NET is not tracked by the EF Core context. Do not expect SaveChanges to detect modifications made to raw data reader results.
Resource disposal: Wrap SqlConnection, SqlCommand, and SqlDataReader in using statements to prevent connection pool exhaustion.
Distributed transactions:TransactionScope across multiple connections is supported on .NET Framework and .NET 7+ only on Windows after setting TransactionManager.ImplicitDistributedTransactions = true; it is not supported on Linux or macOS.
84
What steps do you follow to diagnose and resolve connection failures when using ADO.NET?
KnownRevise
Most ADO.NET connection failures surface as a SqlException thrown by SqlConnection.Open(), and effective troubleshooting means reading the Errors collection and error number rather than the message string alone, then working outward from the connection string to the network and server configuration.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient 5.x against SQL Server (on-premises or Azure SQL). The same principles apply to other providers, but error numbers and messages are SQL Server-specific.
How It Works
SqlConnection is a thin wrapper over the SQL Server network protocol (TDS). When Open() is called, the provider resolves the server name, opens a TCP (or named pipe / shared memory) connection, performs the TLS handshake, and sends the login packet. Any failure in that chain throws SqlException, whose Errors property contains one or more SqlError objects. Each error exposes Number, State, Class, Server, Procedure, LineNumber and Message. The Number is the stable, documented identifier to branch on; the message text can vary between SQL Server versions and languages.
Common Causes and Their Error Numbers
Error Number
Meaning
Typical fix
-2
Timeout expired before login completed
Increase Connect Timeout, check network latency or server load
53, 11001
Server name not found / host does not exist
Correct the Data Source, check DNS or alias
10060
TCP connect failed (firewall, wrong port, server down)
Verify SQL Server is running and TCP is enabled; open port 1433
17
SQL Server does not exist or access denied
Same as above; check the instance name and browser service
4060
Login failed because the requested database cannot be opened
Fix Initial Catalog, grant the login access to the database, or restore the database
18456
Login failed (bad credentials or account locked)
Check user ID/password, Windows account, or contained-user password
18452
Untrusted domain / no trust relationship
Use a SQL login or a trusted domain account
40501, 10928
Azure SQL throttling / resource limits
Retry with backoff; scale the database tier
Diagnostic Steps
Validate the connection string. Confirm Data Source, Initial Catalog, authentication type and Encrypt. Print the resolved string from SqlConnectionStringBuilder in a test harness so typos such as a missing instance name (MSSQLSERVER) become visible.
Isolate the network layer. From the client, test name resolution and reachability of the SQL port before blaming ADO.NET. If TCP works but ADO.NET fails, compare enabled protocols in SQL Server Configuration Manager.
Inspect the exception precisely. Log ex.Number, ex.State, ex.Class, ex.Server and every error in ex.Errors. Class 20 or an error with an empty Server usually means the client never reached the server.
Check TLS and trust. Since Microsoft.Data.SqlClient 4.0 (including 5.x), Encrypt=True is the default; a self-signed or untrusted certificate produces a chain-trust error. Install the certificate, or use TrustServerCertificate=True only for local dev and never in production.
Test outside the application. Connect with sqlcmd or SQL Server Management Studio from the same machine and account. If that also fails, the problem is environmental, not in the code.
Look at the server side. Check the SQL Server error log and, for Azure SQL, the portal’s connection troubleshooting and firewall rules, including whether the client IP is allow-listed.
Code Example
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = "Data Source=sql01.prod.example.com,1433;" +
"Initial Catalog=Sales;User Id=appuser;Password=***;Encrypt=True;" +
"Connect Timeout=15;MultiSubnetFailover=True";
try
{
using var connection = new SqlConnection(connectionString);
connection.Open(); // throws SqlException on any connect or login failure
Console.WriteLine($"Connected to {connection.DataSource}/{connection.Database}");
}
catch (SqlException ex) when (ex.Number == -2 || ex.Number == 10060)
{
// Timeout or TCP failure: network, firewall, or server down.
Console.WriteLine($"Cannot reach SQL Server (error {ex.Number}, state {ex.State}).");
}
catch (SqlException ex)
{
// Log every error in the batch, not just the first message.
foreach (SqlError error in ex.Errors)
{
Console.WriteLine(
$"Number={error.Number} State={error.State} Class={error.Class} " +
$"Server={error.Server} Procedure={error.Procedure} " +
$"Line={error.LineNumber}: {error.Message}");
}
}
Best Practices and Pitfalls
Branch on Number, not on message text — messages are localized and change between versions.
Set an explicit Connect Timeout so a dead server fails fast instead of hanging the caller.
Keep Encrypt=True and fix certificate trust rather than disabling encryption; TrustServerCertificate=True bypasses server identity verification.
Add MultiSubnetFailover=True for Always On listeners and Azure SQL so the client connects to the active replica without waiting for DNS.
Never log or hardcode passwords. Store secrets in a vault or .NET user secrets and build the string with SqlConnectionStringBuilder.
Do not retry in a tight loop after 18456; a bad password will never succeed and repeated attempts can lock the account.
Pool exhaustion looks like a connectivity issue. Leaked, never-closed connections raise “timeout period elapsed” errors at Open(); always wrap SqlConnection in using and clear pools with SqlConnection.ClearPool only when a mirror or failover partner changes.
85
Which utilities and frameworks are available for troubleshooting issues within an ADO.NET data access layer?
KnownRevise
Diagnosing ADO.NET issues relies on a combination of client-side tracing, server-side monitoring, and structured logging to identify bottlenecks and errors across the database boundary.
The SqlConnection class exposes events surfacing database feedback without throwing exceptions. The InfoMessage event captures warnings (severity level 10 or lower) sent via PRINT or low-severity errors. Errors with severity 11 or higher trigger a SqlException, containing SqlError objects detailing number, state, and procedure. Handling InfoMessage allows logging warnings invisible during normal execution.
SQL Server-Side Monitoring
Client-side tools cannot reveal server-side blocking. Extended Events (XEvents) is the modern, low-overhead replacement for deprecated SQL Server Profiler, capturing events like sql_statement_completed and deadlock_graph. Query Store retains historical query performance data within the database. Use server-side tools when client logs indicate timeouts without explicit exceptions.
.NET Tracing and Logging Integration
Microsoft.Data.SqlClient exposes an EventSource for ETW tracing on Windows; the same events flow cross-platform through EventPipe. Subscribe to these events to capture connection pooling statistics, command execution times, and retry behaviour, then route them into Microsoft.Extensions.Logging through an EventSource listener or a compatible logging provider. Propagate correlation IDs through logs to link a web request to its database calls.
IDE and Runtime Diagnostic Tools
Visual Studio provides the Diagnostic Tools window for CPU, memory, and SQL timing. SQL Server Object Explorer inspects schemas directly. On Linux/macOS, where Windows-specific tracing is unavailable, rely on ILogger and server-side XEvents. VS Code with the C# extension supports cross-platform debugging.
Troubleshooting Tools Compared
Tool / Framework
Scope
Platform
Best For
SqlConnection.InfoMessage
Client
Cross-platform
Capturing PRINT output and low-severity warnings
SqlException and SqlError
Client
Cross-platform
Diagnosing failed commands and error details
Extended Events (XEvents)
Server
SQL Server
Low-overhead query and deadlock tracing
Query Store
Server
SQL Server 2016+
Historical performance regression analysis
SqlClient EventSource
Runtime
Windows (ETW), cross-platform (EventPipe)
Pooling statistics and command timing
Visual Studio Diagnostic Tools
IDE
Windows, macOS
CPU, memory, and SQL timing during debugging
Best Practices and Pitfalls
Avoid AddWithValue: Specify SqlDbType and size to prevent implicit conversions and plan cache pollution.
Secure Logging: Never log connection strings, passwords, or parameter values containing PII.
Dispose Resources: Wrap SqlConnection, SqlCommand, and SqlDataReader in using statements to prevent pool exhaustion.
Distinguish Errors: InfoMessage does not catch exceptions; wrap execution in try/catch for SqlException.
Platform Awareness: ETW is Windows-centric; on Linux/macOS, prioritize ILogger and server-side Extended Events.
86
What approaches let you add new capabilities on top of ADO.NET?
KnownRevise
You extend ADO.NET by layering your own APIs on top of its connected classes — most commonly with extension methods on IDbConnection/DbConnection — by deriving from the provider-independent base classes (DbConnection, DbCommand, DbDataReader, DbProviderFactory) to build a decorator or wrapper provider, or by stacking higher-level abstractions such as repositories, unit-of-work, micro-ORMs (Dapper) or EF Core 8 on top of the same ADO.NET primitives.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and Microsoft.Data.SqlClient 5.x (the recommended package for new SQL Server work; the older `System.Data.SqlClient` is still available but in maintenance mode).
Extension Methods
The lightest and most common form of extension is a set of helper methods that take an open connection and encapsulate repetitive code: creating a command, adding typed parameters, executing a reader, and projecting rows.
What to wrap: connection lifetime, parameter binding, DBNull.Value handling, async execution (ExecuteReaderAsync, ReadAsync), and mapping of IDataRecord values to objects.
Why it works: DbDataReader implements IEnumerable, so readers compose naturally with LINQ via Cast<IDataRecord>() while still streaming rows instead of materializing a DataTable.
This is exactly the technique Dapper uses: a micro-ORM delivered as extension methods on IDbConnection, proving that “extending ADO.NET” often means writing a thin, strongly typed layer rather than replacing it.
Deriving from the Db* Base Classes
When you need to change behavior rather than just add convenience, subclass the abstract base classes and delegate to an inner provider instance (the decorator pattern).
DbConnection: override Open, Close, BeginDbTransaction, CreateDbCommand and ChangeDatabase to add logging, metrics, retry, or caching around a real SqlConnection.
DbCommand: override ExecuteDbDataReader (and its async counterpart) to capture command text, durations, and parameters before delegating.
DbDataReader: wrap an inner reader to rename or hide columns, add computed columns, or buffer a forward-only result set.
Prefer wrapping over inheriting from provider classes: deriving from SqlConnection/SqlCommand ties you to one provider and is fragile across versions; a DbConnection subclass that holds an inner DbConnection works with any provider.
Custom DbProviderFactory
A custom DbProviderFactory subclass makes your wrapped provider discoverable and consumable by generic code and by frameworks such as EF Core.
Implement CreateConnection, CreateCommand, CreateParameter, CreateConnectionStringBuilder, and related members so they return your decorated types.
In .NET Core 3.0 and later (including .NET 8) you can register it at startup with DbProviderFactories.RegisterFactory so that code resolving a provider by invariant name (for example through DbProviderFactories.GetFactory) gets your implementation.
This is the integration point for cross-cutting features such as command logging, connection pooling telemetry, or transparent failover, without touching application code.
Higher-Level Layers and Complementary Extensions
ADO.NET is also designed to be the substrate under richer stacks.
Micro-ORMs and full ORMs: Dapper, and EF Core 8, are built on the ADO.NET provider model; choosing them is a legitimate way to “extend” data access while keeping Microsoft.Data.SqlClient underneath.
Server-side type extensions: SQL Server CLR user-defined types and aggregates extend the type system reached through ADO.NET, but they live on the server and are unrelated to client-side API extension.
Streaming and asynchrony: extension methods combining DbDataReader with IAsyncEnumerable<T> and await foreach extend ADO.NET toward modern async streaming workloads.
Best Practices and Limits
Mind deferred execution: a streaming helper built with yield return holds the connection and reader open until enumeration completes, so always fully enumerate (for example with ToList) before the owning using scope disposes the connection.
Keep helpers provider-agnostic where possible: code against DbConnection/DbCommand/IDataRecord so the same extensions work with Microsoft.Data.SqlClient, ODP.NET, Npgsql, and others.
Use Microsoft.Data.SqlClient, not the legacy System.Data.SqlClient, for new SQL Server work on .NET.
Don’t hide ADO.NET fundamentals behind abstractions you cannot debug: keep command text, parameter names, and transaction boundaries visible; wrappers that silently change connection or transaction semantics are a classic production bug source.
Prefer async overloads (OpenAsync, ExecuteReaderAsync, ReadAsync) in any extension intended for server or UI code.
87
In what ways does ADO.NET integrate with the surrounding .NET namespaces and platform features?
KnownRevise
ADO.NET is not a standalone island; it is built from base classes in System.Data and System.Data.Common that other .NET features consume, and it in turn relies on namespaces for transactions, configuration, XML and LINQ. Knowing these integration points helps you pick the right provider, enlist connections in transactions correctly, and avoid obsolete serialization paths.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient and C# 12
System.Data.Common lets you write provider-independent data access by programming against DbConnection and DbCommand instead of concrete SqlConnection types. DbProviderFactory can create these objects from a provider invariant name stored in configuration, which is useful when the target database is chosen at deployment time.
Transactions and Configuration
ADO.NET connections participate in ambient transactions through System.Transactions. Wrapping work in a TransactionScope automatically enlists open connections; on .NET Framework and on .NET 7+ only on Windows this can escalate to a distributed transaction (requires TransactionManager.ImplicitDistributedTransactions = true on .NET 7+), but distributed transactions are not supported on Linux or macOS.
Connection strings and settings live in configuration. In .NET 8 the recommended cross-platform model is Microsoft.Extensions.Configuration (appsettings.json, environment variables, user secrets); System.Configuration (web.config/app.config) is still accessible via the System.Configuration.ConfigurationManager NuGet package and runs on all platforms, but it is legacy and mainly useful when migrating .NET Framework applications.
LINQ, XML and Serialization
LINQ to DataSet: DataTable.AsEnumerable() exposes rows as IEnumerable<DataRow>, and DataRow.Field<T>() gives typed access (use Field<int?>() for nullable columns because Field<int>() throws on DBNull).
Streaming reads: DbDataReader implements IEnumerable, so reader.Cast<IDataRecord>() can be used with LINQ while streaming results.
XML: DataSet.ReadXml/WriteXml and DataTable.WriteXml integrate with System.Xml; XmlWriteMode supports only WriteSchema, IgnoreSchema and DiffGram.
Serialization: DataSet.RemotingFormat = SerializationFormat.Binary relies on BinaryFormatter, which is obsolete and removed in .NET 9; do not use it for new work.
Relationship to Higher-Level Data Technologies
Entity Framework Core 8 is built on top of ADO.NET providers: DbContext opens DbConnection objects from the configured provider (for example Microsoft.Data.SqlClient for SQL Server) and translates LINQ queries into SQL. ASP.NET Core uses ADO.NET indirectly through EF Core or directly via Microsoft.Data.SqlClient in services and minimal APIs, reading connection strings from IConfiguration.
Best Practices and Common Pitfalls
Use Microsoft.Data.SqlClient, not System.Data.SqlClient, for .NET 8.
Always add parameters with an explicit SqlDbType and size; never use AddWithValue.
Send DBNull.Value for database NULLs; a C# null parameter value is treated as not supplied.
Dispose connections, commands and readers with using; enable MARS only when you truly need multiple readers on one connection.
Avoid DataSet binary serialization; prefer System.Text.Json for data transfer.
Remember DataAdapter.Update calls AcceptChanges on each saved row; do not call AcceptChanges again afterward.
88
What approaches does ADO.NET provide for managing concurrent data access, and how do you implement them in a .NET application?
KnownRevise
Concurrency in ADO.NET is handled mainly through optimistic concurrency, which assumes collisions are rare and verifies at update time that a row has not changed since it was read, and pessimistic concurrency, where you lock data inside a transaction for the duration of a unit of work. Optimistic concurrency is the default model for disconnected DataSet/DataTable scenarios; pessimistic locking is used for short, server-side critical sections.
Read full answerHide full answer
Applies to.NET 8 with C# 12 and the `Microsoft.Data.SqlClient` NuGet package (the successor to the legacy `System.Data.SqlClient`, which is deprecated and now ships in-box only with .NET Framework).
Optimistic Concurrency Basics
In a disconnected application you read rows into a DataTable, edit them in memory, and later reconcile changes with SqlDataAdapter.Update. To detect that another user modified the same row in between, the adapter’s UPDATE and DELETE commands include the original values in their WHERE clause. If the row no longer matches, the command affects zero rows and DBConcurrencyException is thrown by Update.
Two common strategies:
Compare all columns: the WHERE clause checks every column against its original value. Simple, but fails when a column type cannot be compared reliably (e.g. float) and produces large SQL text.
Compare a row version column: add a rowversion (formerly timestamp) column to the table. SQL Server bumps it on every change, so the WHERE clause only needs the primary key plus the original rowversion. This is the recommended approach for new tables.
Detecting and Resolving Conflicts
When Update fails, inspect DBConcurrencyException.Row to find the offending DataRow, then choose a resolution policy:
Last write wins: re-read the current row with DataTable.Load or a fresh command and apply the user’s values again.
Force the user’s edit: set adapter.UpdateCommand.UpdatedRowSource = UpdateRowSource.None, clear the original values with row.AcceptChanges() after loading current data, then retry.
Merge and let the user decide: use DataSet.Merge with MissingSchemaAction.Add, then inspect rows whose RowError is set and present a resolution UI.
Merge marks conflicting rows as errored rather than overwriting them, which is why it is the usual basis for a “resolve conflicts” screen.
Pessimistic Concurrency
Pessimistic concurrency keeps locks held by starting a transaction and choosing an isolation level that blocks other writers. It suits short operations where contention is high and rolling back a user’s long edit is unacceptable.
Aspect
Optimistic
Pessimistic
Locks held
None while editing
Held for transaction duration
Scalability
High, works disconnected
Lower, server resources held
Conflict detection
At Update, via WHERE
Prevented by locking
Typical failure
DBConcurrencyException
Deadlocks, lock timeouts
Best for
Read-mostly, long edits
Short critical sections
Isolation level choice matters: System.Data.IsolationLevel.Serializable prevents phantom reads but locks the most; Snapshot uses row versioning in tempdb and avoids read locks, but requires ALLOW_SNAPSHOT_ISOLATION on the database.
Code Example
This example uses a rowversion column for optimistic concurrency and shows a typed conflict-resolution path.
using System.Data;
using Microsoft.Data.SqlClient;
string connectionString = "Server=.;Database=Shop;Integrated Security=true;Encrypt=true;TrustServerCertificate=false;";
// Read a product row into a disconnected DataTable
using var connection = new SqlConnection(connectionString);
var table = new DataTable();
var selectCmd = new SqlCommand(
"SELECT Id, Name, Price, Version FROM Products WHERE Id = @id", connection);
selectCmd.Parameters.Add("@id", SqlDbType.Int).Value = 42;
var adapter = new SqlDataAdapter(selectCmd)
{
UpdateCommand = new SqlCommand(
"UPDATE Products SET Name = @name, Price = @price " +
"WHERE Id = @id AND Version = @version", connection),
UpdatedRowSource = UpdateRowSource.FirstReturnedRecord
};
adapter.UpdateCommand.Parameters.Add("@name", SqlDbType.NVarChar, 100)
.SourceColumn = "Name";
var priceParam = adapter.UpdateCommand.Parameters.Add("@price", SqlDbType.Decimal, 18);
priceParam.Scale = 2;
priceParam.SourceColumn = "Price";
adapter.UpdateCommand.Parameters.Add("@id", SqlDbType.Int)
.SourceColumn = "Id";
adapter.UpdateCommand.Parameters.Add("@version", SqlDbType.Timestamp)
.SourceColumn = "Version";
adapter.Fill(table);
// User edits the row in the UI layer
DataRow row = table.Rows[0];
row["Price"] = 19.99m;
try
{
adapter.Update(table); // Version is checked in the WHERE clause
}
catch (DBConcurrencyException ex)
{
// Someone else changed the row: reload current values and retry once
table.Clear();
adapter.Fill(table);
DataRow current = table.Rows[0];
current["Name"] = "Updated Widget";
current["Price"] = 19.99m;
adapter.Update(table);
}
Note that SqlDataAdapter.Update calls AcceptChanges on each row it saves, so you must not call it yourself after Update; doing so would erase the row state needed for the retry.
Best Practices and Common Pitfalls
Prefer rowversion over comparing all columns: smaller SQL, reliable detection, and no false conflicts from unrelated columns.
Never use AddWithValue: it infers types and can cause plan-cache pollution and implicit conversions. Always use Parameters.Add(name, SqlDbType) with Size, Precision and Scale where relevant.
Keep pessimistic transactions short: open the connection, begin the transaction, run the statements, and commit inside a single using/try block; long-held locks cause deadlocks.
Send DBNull.Value, not C# null: a parameter whose Value is null is treated as not supplied.
Guard against lost updates with Snapshot isolation: readers do not block writers, but you still need optimistic checks because two transactions can both read the same version.
Test conflict paths deliberately: open two connections, read the same row, update both, and verify your resolution code rather than assuming DBConcurrencyException cannot happen.
89
How has ADO.NET evolved and remained relevant as .NET moved from the .NET Framework era to the modern cross-platform releases (.NET Core and .NET 5 through .NET 8)?
KnownRevise
Modern .NET did not discard ADO.NET; it decoupled it from the framework and re-energised it. The SQL Server provider moved out of the framework into the independently versioned Microsoft.Data.SqlClient NuGet package, which is cross-platform, continues to receive new capabilities, and still serves as the low-level data-access layer beneath EF Core and micro-ORMs such as Dapper.
Read full answerHide full answer
Applies to.NET 8 with Microsoft.Data.SqlClient 5.x; .NET Framework 4.6.2+ and `System.Data.SqlClient` where explicitly noted.
From System.Data.SqlClient to Microsoft.Data.SqlClient
System.Data.SqlClient is deprecated: it still ships with .NET Framework and remains available as a NuGet package for older .NET Core targets, but it receives only critical fixes and no new features. Microsoft.Data.SqlClient is its successor and is where all investment goes.
Aspect
System.Data.SqlClient
Microsoft.Data.SqlClient
Status
Deprecated, maintenance only
Actively developed, recommended
Delivery
Built into .NET Framework; NuGet for .NET Core
NuGet only, independent versioning
Platforms
Windows (.NET Framework), limited .NET Core
Windows, Linux, macOS
New features
None
Azure AD auth, enclave support, retry logic, stricter security defaults
The public API is largely source-compatible, so migration is mostly a namespace change plus a review of breaking changes; the invariant provider name and factory are Microsoft.Data.SqlClient and SqlClientFactory.
Cross-Platform Execution
ADO.NET is no longer Windows-bound. On Windows the driver uses the native SNI library, while on Linux and macOS it uses an equivalent managed implementation, so the same connection strings, SqlConnection, SqlCommand and SqlDataReader code runs on all three operating systems. This is what makes containerised and Linux-hosted .NET services with direct SQL Server access practical.
Security Defaults and Authentication
Security is where the modern driver diverges most visibly:
Encrypt now defaults to true (Microsoft.Data.SqlClient 4.0+), whereas the legacy driver defaulted to false; certificates must be valid or TrustServerCertificate must be set deliberately.
TLS 1.2 is the minimum protocol version in Microsoft.Data.SqlClient 2.0 and later.
Azure Active Directory authentication is built in, with SqlAuthenticationMethod.ActiveDirectoryDefault (added in 4.0) the recommended mode for most scenarios; the explicit modes such as ActiveDirectoryManagedIdentity, ActiveDirectoryInteractive and ActiveDirectoryServicePrincipal remain supported when you need to pin a specific credential type.
Always Encrypted with secure enclaves is supported in the modern driver, extending client-side encryption to richer confidential-computing scenarios.
Async, Streaming and Resilience
The classic API shape is unchanged, but modern .NET idioms are fully supported: OpenAsync, ExecuteReaderAsync, ReadAsync and NextResultAsync, IAsyncDisposable on connections, and streaming bulk loads. Because DbDataReader implements IEnumerable, a live reader can be consumed with LINQ via reader.Cast<IDataRecord>() while still streaming rows rather than buffering them. The driver also adds configurable client-side retry logic through SqlConfigurableRetryFactory and SqlConnection.RetryLogicProvider (since 2.0), useful for transient-fault handling that previously required a library such as Polly.
The Disconnected Layer
DataSet, DataTable and SqlDataAdapter still ship and still work, but they are legacy-tier: new code generally prefers EF Core 8 for modelling or Dapper for lightweight mapping, with raw ADO.NET used when maximum control or throughput is required. Note that DataTable binary serialisation (RemotingFormat = Binary) depends on BinaryFormatter, which is obsolete in .NET 8 and removed in .NET 9; use XML serialisation or a modern serialiser instead.
Best Practices
Use Microsoft.Data.SqlClient for all new SQL Server work on .NET; treat System.Data.SqlClient as deprecated.
Always parameterise with an explicit type, e.g. command.Parameters.Add("@id", SqlDbType.Int).Value = id; never use AddWithValue.
Send DBNull.Value for a database NULL, since a C# null is treated as “not supplied”.
Use Field<int?>() on nullable columns, because Field<int>() throws on DBNull.
Prefer async and using blocks so connections are returned to the pool promptly, and let SqlDataAdapter.Update manage row state itself rather than calling AcceptChanges afterwards.