← Open in the full interactive course (progress tracking, search & more)

Every LINQ query you'll ever write against a database eventually becomes a connection, a command, and a reader — this is that layer.

You've spent the last several lessons building on dependency injection, configuration, and logging. Now it's time to make your application actually talk to a database — store an order, look up a customer, update an inventory count. Almost every real-world .NET application does this.

In a few lessons, you'll be using Entity Framework Core to do this with C# objects and LINQ, barely thinking about SQL at all. That's genuinely great for productivity. But EF Core isn't magic — underneath every query it runs, every row it saves, there's a much older, much lower-level piece of .NET quietly doing the actual work: ADO.NET.

In this lesson, you'll learn what ADO.NET is, the handful of building-block types it's built from, and why understanding this "under the floorboards" layer will make you a noticeably better EF Core developer — not a worse one.

What Is It?

The Simple Explanation

ADO.NET is the part of .NET whose entire job is: open a connection to a database, send it a command written in that database's own language (SQL), and hand back whatever data comes back — as raw rows and columns, not as C# objects. It doesn't know what a Product class is. It only knows connections, commands, and rows of data.

The Technical Definition

ADO.NET is a set of classes, defined mostly in the System.Data and System.Data.Common namespaces, that provide a standard, provider-independent way to interact with relational databases. It defines a small family of abstract base types — DbConnection, DbCommand, DbDataReader, DbParameter, DbTransaction — and each database vendor ships a provider that implements those abstractions for their specific database engine.

For SQL Server, that provider is the Microsoft.Data.SqlClient NuGet package (the modern, actively-maintained namespace — not the older, legacy System.Data.SqlClient you may still see in tutorials). It gives you concrete types like SqlConnection, SqlCommand, and SqlDataReader, each of which derives from the provider-agnostic DbConnection, DbCommand, and DbDataReader base classes.

Provider-Agnostic Base TypeSQL Server ImplementationWhat It Represents
DbConnectionSqlConnectionAn open line to a specific database
DbCommandSqlCommandOne SQL statement you want to run
DbDataReaderSqlDataReaderA fast, forward-only stream of result rows
DbParameterSqlParameterA single, safely-typed value passed into a command
DbTransactionSqlTransactionA group of statements that must all succeed or all fail together

Every one of these gets its own dedicated lesson right after this one — this lesson is about seeing how they fit together as a system, before zooming into each piece.

Why Does It Exist?

The Problem — Databases Speak SQL, .NET Speaks Objects

A relational database like SQL Server, PostgreSQL, or SQLite doesn't understand C# classes, properties, or objects. It understands a text-based query language — SQL — sent over a network connection, and it responds with rows and columns of raw data (strings, numbers, dates — no types like Product or Customer exist on its side at all).

Somewhere, something has to:

Before a standard existed, every database vendor had its own bespoke way of doing this from .NET, and code that talked to SQL Server looked nothing like code that talked to Oracle. Switching databases meant rewriting your entire data-access layer from scratch.

The Solution — A Common Shape, Provider-Specific Implementations

ADO.NET's answer was to define one common shapeDbConnection, DbCommand, DbDataReader, and friends — that every database provider agrees to implement. Your code, working against those shapes, looks structurally the same whether you're talking to SQL Server, PostgreSQL, or SQLite. Only the concrete provider types and the connection string change.

Without a Common Layer

With ADO.NET

Big Picture

Where does ADO.NET actually sit? Right between your application code and the physical database server:

THE DATA-ACCESS STACK
Your Application Code
Business logic — e.g. "place this order"
Entity Framework Core (next several lessons)
Translates LINQ / C# objects into SQL and back — an ORM built on ADO.NET
ADO.NET (this lesson)
DbConnection → DbCommand → DbDataReader — raw SQL in, raw rows out
Database Provider (Microsoft.Data.SqlClient, Npgsql, etc.)
Speaks the actual network protocol of the specific database engine
The Database Server
SQL Server, PostgreSQL, SQLite, etc. — stores and returns your data

Notice that EF Core doesn't skip ADO.NET — it uses it. Every query EF Core runs still ultimately becomes a DbCommand executed over a DbConnection, whose results come back through a DbDataReader. EF Core's real job is generating that SQL for you and mapping the reader's rows back into your C# objects automatically.

How It Works

THE FOUR-STEP ADO.NET PATTERN
Step 1 — Open a connection
Step 2 — Build a command
Step 3 — Execute and read
Step 4 — Dispose everything

This four-step shape — connect → command → execute/read → dispose — is the backbone of every single ADO.NET interaction, and it's exactly what EF Core is automating for you under the hood.

Simple Example

Reading a list of product names directly with ADO.NET, no EF Core involved at all:

using Microsoft.Data.SqlClient; string connectionString = "Server=localhost;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True;"; using SqlConnection connection = new(connectionString); connection.Open(); using SqlCommand command = connection.CreateCommand(); command.CommandText = "SELECT Id, Name, Price FROM Products WHERE Price > @minPrice"; command.Parameters.AddWithValue("@minPrice", 20.00m); using SqlDataReader reader = command.ExecuteReader(); while (reader.Read()) { int id = reader.GetInt32(0); string name = reader.GetString(1); decimal price = reader.GetDecimal(2); Console.WriteLine($"{id}: {name} - {price:C}"); } // connection and reader are disposed automatically when they go out of scope

Walking through it:

That manual mapping is exactly what EF Core exists to eliminate. Imagine writing that reader.GetXxx(ordinal) dance for every property, on every entity, for every single query in a real application. That repetitive, error-prone boilerplate is the single biggest reason ORMs like EF Core exist — you'll see it explicitly in the next lesson.

Real-World Example

Even in an application built entirely on EF Core, ADO.NET doesn't disappear from view completely. A common real-world scenario: a nightly reporting job needs to run a heavily-optimized, hand-tuned aggregate query — total revenue per region for the last quarter — where every millisecond and every byte of the generated SQL matters, and the team wants full manual control rather than trusting an ORM to generate exactly the right query plan.

using Microsoft.Data.SqlClient; public async Task<List<RegionRevenue>> GetQuarterlyRevenueByRegionAsync(string connectionString) { var results = new List<RegionRevenue>(); await using SqlConnection connection = new(connectionString); await connection.OpenAsync(); await using SqlCommand command = connection.CreateCommand(); command.CommandText = """ SELECT Region, SUM(TotalAmount) AS Revenue FROM Orders WHERE OrderDate >= @quarterStart GROUP BY Region ORDER BY Revenue DESC """; command.Parameters.AddWithValue("@quarterStart", DateTime.UtcNow.AddMonths(-3)); await using SqlDataReader reader = await command.ExecuteReaderAsync(); while (await reader.ReadAsync()) { results.Add(new RegionRevenue( reader.GetString(0), reader.GetDecimal(1))); } return results; } public record RegionRevenue(string Region, decimal Revenue);

This is a genuinely common pattern in real .NET systems: use EF Core for the vast majority of everyday CRUD-style data access, but reach for raw ADO.NET (or EF Core's own raw-SQL escape hatches, which you'll meet later) for the small handful of queries where hand-tuned SQL matters more than convenience.

Analogy

A Phone Call vs. a Personal Assistant

ADO.NET is like making the phone call yourself: you dial the number (open the connection), read out exactly what you want (the SQL command), and personally write down every detail of the answer, word for word (reading the raw data reader).

EF Core is like having a personal assistant: you tell them "get me last quarter's top customers," and they make the call, take the notes, and hand you back a neatly organized report (fully-formed C# objects) — but underneath, they're still making the exact same kind of phone call you would have made yourself.

Knowing how to make the call yourself means you understand what your assistant is actually doing — and you can step in and make the call personally when you need that extra level of control.

Under the Hood

This is worth being explicit about, because it's the single most useful mental model for the rest of Part VI: Entity Framework Core is, at its core, a very sophisticated generator of ADO.NET calls. When you write:

var expensiveProducts = await dbContext.Products .Where(p => p.Price > 20) .ToListAsync();

EF Core does roughly the same four steps you just wrote by hand above:

WHAT EF CORE IS DOING BEHIND YOUR LINQ QUERY
1. Translate your LINQ expression into a SQL string
2. Get (or open) a DbConnection from the pool
3. Build a DbCommand with that SQL text and DbParameters for your values (like 20 above)
4. Call ExecuteReaderAsync() and get back a DbDataReader
5. Loop through the reader and materialize a Product object per row — the exact manual mapping you wrote by hand earlier, done automatically

Every "mystery" you'll ever encounter in EF Core — a slow query, an unexpected SQL statement, a connection-pool exhaustion error — ultimately traces back down to this same ADO.NET layer. That's precisely why this module starts here instead of jumping straight to DbContext.

Common Confusion

1. "ADO.NET is old and obsolete, EF Core replaced it"

ADO.NET isn't a competing, outdated alternative to EF Core — it's the foundation EF Core is built on. EF Core doesn't replace ADO.NET; it wraps it and generates its calls for you. Even in the newest .NET 10 applications built entirely with EF Core, ADO.NET types are working underneath every single query, whether you ever see them or not.

2. "Microsoft.Data.SqlClient vs. System.Data.SqlClient — does it matter which one?"

Yes. System.Data.SqlClient is the legacy, in-maintenance-mode namespace that shipped with the full .NET Framework. Microsoft.Data.SqlClient is its modern, actively-developed successor — it's what EF Core's SQL Server provider uses internally, and it's what you should reach for in any new .NET code that talks to SQL Server directly.

3. "If I use EF Core, I never need to know ADO.NET"

You can absolutely be productive with EF Core without ever writing a line of raw ADO.NET. But when a query behaves unexpectedly, when you need to reason about connection pooling, or when you hit EF Core's raw-SQL escape hatches, understanding this layer is what turns "EF Core is doing something weird" into "I know exactly what SQL and connection behavior is happening here."

Common Mistakes

Mistake 1 — Treating ADO.NET types as something you'll "never touch"

Some learners skip straight to EF Core and treat ADO.NET as irrelevant trivia. In practice, this layer resurfaces constantly: connection string troubleshooting, understanding timeout exceptions, reading logged SQL, and occasionally writing a raw query for performance. A basic working knowledge pays for itself repeatedly.

Mistake 2 — Using the legacy System.Data.SqlClient in new code

using System.Data.SqlClient; in a brand-new .NET 10 project — it still works, but it's effectively frozen, receiving only critical security fixes. Reference the Microsoft.Data.SqlClient NuGet package instead; it's a near drop-in replacement with active development, better performance, and newer SQL Server feature support.

Mistake 3 — Assuming EF Core is "slower ADO.NET" and always writing raw queries out of distrust

Reaching for raw ADO.NET for every single query "because it's faster," before ever measuring anything. For the overwhelming majority of everyday queries, EF Core's generated SQL is perfectly efficient, and the productivity and maintainability gains are enormous. Default to EF Core; drop to raw ADO.NET (or EF Core's raw-SQL features) only for the specific, measured cases where it demonstrably matters.

When Should I Use It?

Hand-tuned, critical queries
A reporting job or hot-path query where you need exact control over the generated SQL.
Legacy codebases
Older systems written before EF Core (or any ORM) was adopted.
Learning the foundation
Understanding it — even if you rarely write it by hand — makes you far better at debugging EF Core.
Everyday application code
For this, prefer EF Core — that's exactly what the rest of Part VI builds toward.
Rule of thumb: Learn ADO.NET to understand what's happening beneath EF Core — but for day-to-day application code, EF Core's productivity, safety (built-in parameterization), and maintainability advantages make it the default choice in modern .NET development.

Mental Model

DbConnection = the open line to the database.
DbCommand = the SQL statement you want to run over that line.
DbDataReader = the stream of raw rows coming back.

Remember:
· Every database provider (SQL Server, PostgreSQL, SQLite) implements the same ADO.NET shape.
· EF Core doesn't replace ADO.NET — it generates ADO.NET calls for you automatically.
· Manual reader-to-object mapping is exactly the boilerplate EF Core exists to remove.

Key Takeaway


Check Your Understanding

You've seen the layer that sits beneath every database interaction in .NET. Let's check it stuck.

1. What is the relationship between ADO.NET and Entity Framework Core?

Show answer

Correct: B

Why B is correct: EF Core is a higher-level abstraction that, underneath, still opens a DbConnection, builds a DbCommand, and reads results through a DbDataReader — exactly the ADO.NET pattern. It automates that layer rather than replacing it.

Why A is incorrect: They aren't competitors in the sense of mutually exclusive technologies — EF Core depends on ADO.NET to function at all.

Why C is incorrect: It's the reverse — ADO.NET predates EF Core and is the lower-level foundation, not a newer replacement.

Why D is incorrect: Neither EF Core nor ADO.NET replaces the database server — both are ways for .NET code to talk to it.

Reinforcement: EF Core is best understood as "ADO.NET, automated" — not a separate, unrelated technology.

2. Which ADO.NET type is responsible for streaming the actual rows returned by a query back to your code?

Show answer

Correct: C

Why C is correct: A DbDataReader (e.g. SqlDataReader) is a forward-only cursor over the result rows — you call Read() repeatedly to advance through them.

Why A is incorrect: A DbConnection represents the open line to the database — it doesn't itself hold row data.

Why B is incorrect: A DbCommand represents the SQL statement to execute — executing it is what produces a reader, but the command itself isn't the row stream.

Why D is incorrect: A DbParameter represents a single safely-typed input value passed into a command — it has nothing to do with reading results back.

Reinforcement: Connection opens the line, command describes the request, reader delivers the response.

3. Why does ADO.NET define provider-agnostic base classes like DbConnection rather than requiring every vendor to invent its own unrelated API?

Show answer

Correct: B

Why B is correct: The shared base types mean your data-access code looks structurally similar regardless of which database you're targeting, and it lets frameworks like EF Core be built against one common abstraction instead of one per database vendor.

Why A is incorrect: ADO.NET doesn't equalize performance — different databases and providers still perform differently; it only standardizes the API shape.

Why C is incorrect: They are genuinely different database engines with different internals — ADO.NET standardizes how .NET code talks to them, not what they are underneath.

Why D is incorrect: ADO.NET is literally the mechanism for sending raw SQL — it doesn't discourage or prevent it.

Reinforcement: A shared abstraction is what makes portability and higher-level tooling like EF Core possible in the first place.

4. Which NuGet package should a new .NET 10 project use to talk to SQL Server via ADO.NET directly?

Show answer

Correct: B

Why B is correct: Microsoft.Data.SqlClient is the modern SQL Server ADO.NET provider — the one EF Core's SQL Server provider itself relies on internally, and the recommended choice for new code.

Why A is incorrect: System.Data.SqlClient still works but is legacy and receives only critical fixes — not the right choice for new projects.

Why C is incorrect: ADO.NET providers exist for many databases, including SQL Server, PostgreSQL, and SQLite — it's not SQLite-only.

Why D is incorrect: EF Core's SQL Server provider itself depends on Microsoft.Data.SqlClient under the hood — it doesn't eliminate the need for a database client library, it builds on one.

Reinforcement: Always favor Microsoft.Data.SqlClient over the legacy System.Data.SqlClient in modern .NET code.

You now understand the ADO.NET layer that quietly powers every database interaction in .NET. Next up: a closer look at the first building block — database connections.


dotnetmadeeasy.com — Learn C# and .NET, the right way.