Every .NET data library, EF Core included, ultimately runs on top of ADO.NET, so a senior engineer is expected to know what happens below the ORM even if they rarely write raw SqlCommand code day to day. Interviewers reach for ADO.NET and Dapper questions specifically to test that foundation: connection pooling behavior under load, why a DataReader matters for memory, what a bulk copy actually does differently from batched inserts, and — always — whether a candidate can be trusted not to introduce a SQL injection vulnerability the moment a query needs to be dynamic. This is also where "when do you drop below EF Core" gets a real answer instead of a reflexive one. These ten questions cover pooling internals, streaming, timeouts, SqlBulkCopy, Dapper's multi-mapping and buffering behavior, parameterization, transactions across libraries, and Native AOT.
Q1 Explain how ADO.NET connection pooling works internally. What causes pool exhaustion and timeouts?#
Short answer: Each unique connection string gets its own pool of physical connections (further split by Windows identity under integrated security); Open takes an idle connection from that pool instead of establishing a new TCP and login handshake, and Close or Dispose returns it rather than tearing it down. Pool exhaustion almost always means connections are being held too long or leaked, not that the pool itself is undersized.
SQL Server's client driver defaults Max Pool Size to 100 connections per pool, and Min Pool Size to 0, so the pool grows lazily as concurrent demand requires it, up to that ceiling. When every pooled connection is busy and the ceiling is reached, a new request waits up to the connect timeout — 15 seconds by default — and then fails with a pool timeout exception. That failure is a symptom, and the cause is nearly always one of: a connection opened but never disposed (missing await using), a connection held open across a slow external call or user interaction, or a genuine spike in concurrent load that outpaces 100 connections per application instance. Raising Max Pool Size treats the symptom; auditing for undisposed connections and shortening how long each one is held treats the cause. The pool is also cleared automatically after certain fatal errors, such as a failover, and idle connections are recycled after several minutes, so a pool that looks unhealthy after a failover often self-heals once new connections replace the stale ones.
What interviewers look for: citing the actual default (100) instead of guessing; connecting pool timeouts to leaks and long-held connections first, before "just raise the limit"; understanding that pooling is keyed by connection string.
Common mistakes: raising Max Pool Size as the first response to a timeout without checking for leaked connections; assuming every application instance shares one global pool, when each process has its own.
Q2 How does a DataReader stream results, and when would you choose it over materializing a full list?#
Short answer: DbDataReader reads rows forward-only, one at a time, directly off the network stream, and holds nothing in memory beyond the current row unless your code buffers it — which makes it the most memory-efficient way to process a large or unbounded result set, at the cost of keeping the connection open and the command outstanding for as long as you are iterating.
await using var reader = await command.ExecuteReaderAsync(ct);
int idOrdinal = reader.GetOrdinal("Id"); // resolve ordinals once, outside the loop
while (await reader.ReadAsync(ct))
{
yield return reader.GetInt32(idOrdinal);
}Choose a reader directly when you are exporting, streaming to an HTTP response, or feeding another sink such as SqlBulkCopy, where holding the entire result in memory first would be wasteful or impossible at scale. Choose materialization (a List<T>, or Dapper's default buffered Query<T>) for typical request handling, because it releases the connection and any locks quickly and is simpler to reason about — the connection is only open for the fetch, not for however long your business logic downstream takes to run. Resolving column ordinals once outside the read loop, rather than looking up by name on every row, is a small but real cost most hand-written readers get wrong.
What interviewers look for: the memory-versus-connection-lifetime trade-off stated explicitly, not just "readers are faster"; a concrete scenario where streaming is the right default; the ordinal-lookup detail as a sign of real experience with raw ADO.NET.
Common mistakes: holding a reader open across a slow downstream operation, which ties up a pooled connection for far longer than necessary; looking up columns by name inside the loop instead of once before it.
Q3 What does CommandTimeout control, and how is it different from a connection timeout?#
Short answer: CommandTimeout (30 seconds by default on SqlCommand) bounds how long a single command is allowed to run once it starts executing against an already-open connection; the connect timeout — 15 seconds by default — bounds only how long establishing that connection is allowed to take. They fail with different exceptions at different stages, and confusing them leads to tuning the wrong knob.
A slow query that exceeds CommandTimeout throws a timeout exception from ExecuteReaderAsync or ExecuteNonQueryAsync, after the connection was already successfully opened; a database that is unreachable or overloaded at the network or login level instead fails to open the connection within the connect timeout, before any command runs. Setting CommandTimeout to 0 means "wait indefinitely," which is occasionally correct for a known long-running batch job but is dangerous as a default, since it turns a stuck query into a silent hang rather than a visible failure. In Dapper, CommandDefinition carries its own timeout per call, so you can give a reporting query a longer allowance than the default without changing it globally for every other query on the same connection.
var report = await connection.QueryAsync<ReportRow>(new CommandDefinition(
sql, parameters, commandTimeout: 120, cancellationToken: ct)); // this call onlyWhat interviewers look for: clearly separating "time to open a connection" from "time to run a command"; knowing the actual default values rather than approximating; awareness that CommandTimeout = 0 is a deliberate, risky choice, not a safe default.
Common mistakes: raising the global command timeout application-wide to fix one slow report query, which hides genuine performance regressions on every other query; conflating a connection pool timeout with either of these.
Q4 When and how would you use SqlBulkCopy, and what trade-offs does it have?#
Short answer: SqlBulkCopy streams rows to SQL Server using the same bulk-load protocol as the bcp tool, making it typically the fastest way to load large volumes from .NET — far faster than batched INSERT statements — at the cost of skipping constraint checks and triggers by default and not being transactional across the whole load unless you explicitly ask for that.
using var bulk = new SqlBulkCopy(connection, SqlBulkCopyOptions.TableLock, null)
{
DestinationTableName = "catalog.PriceStaging",
BatchSize = 5_000,
};
bulk.ColumnMappings.Add("Sku", "Sku");
bulk.ColumnMappings.Add("Price", "Price");
await bulk.WriteToServerAsync(dataReader, ct); // an IDataReader source avoids buffering everything firstIts source can be a DataTable, a DataRow[], or any IDataReader, which matters because passing a reader with EnableStreaming set lets you pipe rows from another database or a file parser without materializing the whole set in memory first. By default, each bulk copy operation is its own non-transacted unit: batches already written stay committed even if a later batch fails, which is usually not what you want for correctness-sensitive loads, so wrap it with SqlBulkCopyOptions.UseInternalTransaction for per-batch transactions, or hand it an existing SqlTransaction to fold the whole load into a larger unit of work. Because constraint checks and triggers are skipped unless you set CheckConstraints and FireTriggers, the common, safe pattern is to load into a staging table with bulk copy, then MERGE into the real target table in one set-based statement that does enforce your business rules.
What interviewers look for: naming the staging-table-plus-merge pattern as the safe default; knowing that constraints and triggers are skipped unless explicitly enabled; distinguishing bulk copy's non-transactional default from a normal batched insert inside a transaction.
Common mistakes: bulk-loading directly into a live table with foreign keys and assuming they were enforced; assuming the whole operation rolls back together on failure without setting UseInternalTransaction or passing a transaction explicitly.
Q5 Explain Dapper's multi-mapping API and when you would use QueryMultiple instead.#
Short answer: Multi-mapping splits a single flat joined row into several typed objects at a column boundary (splitOn) and lets a delegate stitch them into an object graph in one round trip; QueryMultiple instead sends several independent SQL statements in one round trip and reads each result set separately. Reach for multi-mapping when you have one join producing a parent with its children; reach for QueryMultiple when you need several logically separate shapes, such as a header, a list and a count.
var orders = new Dictionary<int, OrderWithLines>();
await connection.QueryAsync<OrderWithLines, OrderLineRow, OrderWithLines>(
new CommandDefinition(joinSql, new { customerId }, cancellationToken: ct),
(order, line) =>
{
if (!orders.TryGetValue(order.Id, out var existing))
{
existing = order;
orders.Add(order.Id, existing);
}
existing.Lines.Add(line);
return existing;
},
splitOn: "Id");The dictionary is required because a join repeats the parent's columns on every child row, so without de-duplication you would get the same order once per line. For a parent with several independent child collections, or a page that needs a header plus a paged list plus a total count, QueryMultiple is often both simpler and cheaper than one wide join, because it avoids that row duplication entirely — you read one grid per shape instead of reconstructing a graph from a flattened join.
What interviewers look for: understanding what splitOn actually does and why de-duplication is necessary; a clear rule for choosing between multi-mapping and QueryMultiple rather than defaulting to one for everything.
Common mistakes: forgetting the de-duplicating dictionary and ending up with duplicate parent objects; using a single wide multi-mapped join for a parent with several unrelated child collections, which multiplies rows combinatorially instead of using QueryMultiple.
Q6 Dapper buffers results by default. What does that mean, and when would you turn it off?#
Short answer: Query<T> reads the entire result set into a List<T> before returning, which releases the underlying connection and any locks quickly and is the right default for typical request-sized results; for results too large to hold comfortably in memory, QueryUnbufferedAsync<T> streams rows lazily as an IAsyncEnumerable<T> instead, keeping the connection open for as long as you enumerate.
await foreach (var row in connection.QueryUnbufferedAsync<ExportRow>(
new CommandDefinition(sql, cancellationToken: ct)).WithCancellation(ct))
{
await writer.WriteLineAsync(row.ToCsvLine());
}The trade-off mirrors the raw DataReader decision: buffered is simpler and frees resources sooner, which is what you want inside a typical request handler; unbuffered avoids loading, say, a multi-gigabyte export into memory at once, at the cost of holding the connection and any read locks open for the whole streaming operation. Cancellation for an unbuffered query has to be threaded through WithCancellation, not just the CommandDefinition's token, since you are now iterating an async sequence rather than awaiting a single call.
What interviewers look for: knowing buffered is the default and why that is usually correct; a concrete threshold for reaching for unbuffered results (export jobs, very large or unbounded results) rather than treating it as a general performance switch.
Common mistakes: reaching for unbuffered queries by default "for performance," which mostly just holds connections open longer for typical, modestly sized results; forgetting WithCancellation on the enumeration itself.
Q7 How do you guarantee protection against SQL injection with dynamic queries in Dapper or raw ADO.NET?#
Short answer: Every value travels as a parameter, never as concatenated text, which Dapper makes the easy path since it turns every property of an anonymous object into a real DbParameter; the part that still needs discipline is identifiers — column names, sort directions, table names — which cannot be parameterized at all and must instead be chosen from a fixed allow-list in code.
var orderBy = sortField switch
{
"name" => "Name",
"price" => "Price",
_ => "Id",
};
var sql = $"SELECT Id, Name, Price FROM Products WHERE 1 = 1 ORDER BY {orderBy}";List expansion is safe and convenient (WHERE Id IN @ids becomes IN (@ids1, @ids2, ...) automatically), but SQL Server allows at most 2,100 parameters per request, so very large key sets need a table-valued parameter or a JSON array bound as a single parameter instead of naive list expansion. For a varchar column, sending a plain string parameter lets the driver infer nvarchar, which forces an implicit conversion on every row and silently turns an index seek into a scan; binding it explicitly as an ANSI string (Dapper's DbString, or an explicit SqlDbType in raw ADO.NET) avoids both the security and the performance problem at once. Dynamic WHERE clauses built up conditionally are still safe as long as every appended fragment binds its value through a parameter, never through string interpolation of user input.
What interviewers look for: the identifiers-cannot-be-parameters distinction, stated precisely, not just "always use parameters"; awareness of the 2,100-parameter limit and its practical fix; connecting parameter typing to both security and index-seek performance.
Common mistakes: believing Dapper alone is sufficient without also allow-listing dynamic column and sort-direction input; concatenating "just this one" filter fragment directly into SQL text, which is how most real injection bugs start.
Q8 When would you drop down from EF Core to Dapper or raw ADO.NET, and how do you justify it to a team?#
Short answer: Justify it with a specific, named reason — a measured hot path where EF Core's overhead matters, reporting SQL with window functions or pivots that LINQ expresses poorly, a bulk load, a provider-specific feature, or a database you do not own and cannot model cleanly — never with "Dapper is faster" as a blanket claim, and check first whether the real problem is an N+1 query or a missing index that EF Core could fix on its own.
For simple reads, Dapper typically has less overhead because it skips LINQ translation and change tracking entirely, but the gap has narrowed enough that a well-written EF Core query with projection and AsNoTracking is often close enough that database time dominates either way. The decision that holds up in a design review is not "which is faster in a microbenchmark" but "which of these named reasons applies here": if none does, staying on EF Core keeps the codebase consistent and keeps change tracking, migrations and LINQ available for the next developer. See EF Core Performance Tuning for fixing the N+1-and-missing-index case first, and the Dapper and ADO.NET guide for the lower-level mechanics once dropping down is actually justified.
What interviewers look for: a specific, falsifiable justification rather than a blanket performance claim; evidence that the candidate checks for EF Core misuse before reaching for a different library; comfort with both libraries coexisting in the same codebase for different responsibilities.
Common mistakes: claiming Dapper is "always faster" without having measured; rewriting a slow EF Core query in Dapper without first checking whether it was missing an index or triggering N+1 queries.
Q9 How do you handle transactions correctly when mixing Dapper and EF Core, or several Dapper calls, in one unit of work?#
Short answer: A transaction belongs to a connection, and every command that must participate has to reference it explicitly — in Dapper, through the CommandDefinition's transaction argument — so the most common bug is simply forgetting to pass it on one call, which either fails outright or silently runs outside the transaction. To mix Dapper into an EF Core unit of work, take the already-open connection and transaction from the DbContext and pass both to Dapper.
await using var tx = await db.Database.BeginTransactionAsync(ct);
db.Orders.Add(order);
await db.SaveChangesAsync(ct); // EF Core write
var connection = db.Database.GetDbConnection(); // already open, inside the same transaction
await connection.ExecuteAsync(new CommandDefinition(
"UPDATE CustomerStats SET OrderCount = OrderCount + 1 WHERE CustomerId = @Id",
new { order.CustomerId }, tx.GetDbTransaction(), cancellationToken: ct)); // Dapper write
await tx.CommitAsync(ct);One detail trips up experienced developers: if the DbContext is configured with a retrying execution strategy such as EnableRetryOnFailure, EF Core refuses to let you start a user-initiated transaction directly, because a retry has to replay the whole unit of work, not just the part after the transaction began. The fix is to wrap the entire block inside db.Database.CreateExecutionStrategy().ExecuteAsync(...) so the strategy can retry the transaction as a whole, Dapper calls included.
What interviewers look for: the transaction-belongs-to-a-connection framing rather than assuming Dapper "just knows" about an ambient transaction; the specific execution-strategy interaction, which is a strong signal of hands-on EF Core experience.
Common mistakes: forgetting to pass the transaction on one Dapper call inside a multi-statement unit of work, which throws or silently escapes the transaction depending on the provider; starting a manual transaction on a context configured with automatic retries and hitting an exception at runtime.
Q10 What's different about running ADO.NET and Dapper under Native AOT, and what would you check before shipping it?#
Short answer: Classic Dapper generates IL at runtime the first time it sees a given query's shape, which the JIT handles fine but which is invisible to the trimmer and impossible under Native AOT; Dapper.AOT replaces that with C# interceptors that generate the same mapping code at build time, without changing your call sites, but it does not cover every Dapper API yet, so you have to verify what actually got intercepted.
<PropertyGroup>
<InterceptorsNamespaces>$(InterceptorsNamespaces);Dapper.AOT</InterceptorsNamespaces>
<PublishAot>true</PublishAot>
</PropertyGroup>Opting in requires [module: DapperAot] (or [DapperAot] per type or method); only direct, inline Dapper calls using generic methods such as Query<T> are intercepted, QueryMultiple currently falls back to classic runtime-generated Dapper regardless, and any SqlMapper.AddTypeHandler registered at runtime is invisible to the generator, so type handlers need a module-level [TypeHandler] attribute instead to be picked up at build time. Before shipping, publish the actual application with PublishAot=true and exercise every Dapper call path, not just a sample — a call that silently falls back to classic Dapper still works under the JIT but fails at publish or at runtime under a real AOT binary — and confirm the underlying ADO.NET provider (Microsoft.Data.SqlClient, Npgsql) itself supports Native AOT, since Dapper.AOT does not paper over a provider that does not.
What interviewers look for: understanding exactly why classic Dapper breaks under AOT (runtime IL generation, invisible to the trimmer); specific, current limitations of Dapper.AOT rather than "it just works now"; the discipline of testing an actual AOT publish rather than trusting the JIT build.
Common mistakes: assuming installing the Dapper.AOT package alone changes behavior without the opt-in attribute; shipping QueryMultiple calls under AOT untested, assuming Dapper.AOT covers every API uniformly.
Quick-Fire Round#
| Question | Answer |
|---|---|
| SQL Server driver's default Max Pool Size? | 100 connections |
| Default SqlCommand CommandTimeout? | 30 seconds |
| Default ADO.NET connect timeout? | 15 seconds |
| Does Query<T> buffer results by default in Dapper? | Yes |
| What Dapper method streams results as IAsyncEnumerable<T>? | QueryUnbufferedAsync |
| What splits a joined row into parent and child objects in Dapper? | splitOn in multi-mapping |
| Can column names or sort directions be parameterized? | No, use an allow-list |
| Max parameters per SQL Server request? | 2,100 |
| What does SqlBulkCopy skip by default? | Constraint checks and triggers |
| Why does classic Dapper fail under Native AOT? | It generates IL at runtime |
How to Prepare#
- Be able to state the actual default values (pool size, connect timeout, command timeout) rather than approximating them; interviewers at this level often ask directly.
- Practice writing the transaction-sharing pattern between EF Core and Dapper from memory, including the execution-strategy interaction.
- Know the identifiers-versus-values distinction for SQL injection cold, with the allow-list pattern ready to write.
- Rehearse a specific, evidence-based justification for dropping to Dapper, not a general performance claim, and check EF Core misuse (N+1, missing indexes) as the alternative explanation first.
- Understand Dapper.AOT's current limitations well enough to say what you would test before an AOT publish, not just that the package exists.