Your First Query and Save
By the end of this lesson
Read and write data, and see the SQL that was generated.
Time to run something. You will install a provider, switch on SQL logging, read some products, write an order, and read the log to see exactly what the database was asked to do.
The order of those steps is deliberate. Logging comes before the first query, not after the first problem.
# The provider: EF Core plus everything specific to SQL Server
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
# Design-time support for the migrations commands in a later lesson
dotnet add package Microsoft.EntityFrameworkCore.Design
# The command-line tool, installed once per machine
dotnet tool install --global dotnet-ef
# Check it responds
dotnet ef --version- There is no separate EF Core package to install. The provider package depends on it, so one line brings both.
- Swap the provider for a different database: Npgsql.EntityFrameworkCore.PostgreSQL for PostgreSQL, Microsoft.EntityFrameworkCore.Sqlite for SQLite. The model and most queries stay as they are; generated SQL differs.
- The Design package is needed by the tooling, not at run time. Add it to the project that holds your DbContext.
- If dotnet ef is already installed and out of date, dotnet tool update --global dotnet-ef brings it in line with your packages.
builder.Services.AddDbContext<AnviContext>(options =>
{
options.UseSqlServer(
builder.Configuration.GetConnectionString("AnviDatabase"));
if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information);
options.EnableSensitiveDataLogging();
}
});- LogTo takes any method that accepts a string, so Console.WriteLine is enough to start with. At Information level you get one entry per command, including the SQL and how long it took.
- EnableSensitiveDataLogging adds the parameter values to those entries. Without it you see @p0 and no indication of what @p0 held, which removes most of the diagnostic value.
- The environment check matters. Parameter values can include names, addresses and anything else you queried by, so this setting writes personal data into your logs. Development only.
- In a console application without dependency injection, the same two calls go on the DbContextOptionsBuilder inside OnConfiguring.
// Read: which products are still available?
List<Product> available = await context.Products
.Where(p => !p.IsDiscontinued)
.OrderBy(p => p.Name)
.ToListAsync();
// Write: place an order for the first two of them
Order order = new()
{
CustomerId = 4,
PlacedOn = DateTime.UtcNow,
Reference = "ORD-10421",
Items = available.Take(2)
.Select(p => new OrderItem
{
ProductId = p.Id,
Quantity = 1,
UnitPrice = p.UnitPrice
})
.ToList()
};
context.Orders.Add(order);
await context.SaveChangesAsync();
Console.WriteLine(order.Id); // set by the database, read back for you- Add marks the order for insertion. Nothing is sent yet — the context is collecting work.
- The OrderItem objects are reachable through the order's Items collection, so EF Core discovers them and inserts them too. You do not add each child separately.
- SaveChangesAsync sends everything in one transaction, parents before children, so the order exists before rows reference it. A failure anywhere rolls the whole thing back.
- order.Id was zero until the save. EF Core reads the generated key back and assigns it, which is also how the OrderItem rows got the right foreign key.
- UnitPrice is copied onto the order item rather than read from the product later. That is a modelling decision, not an EF one: an order should record the price charged at the time, not follow the product's price forever.
-- The read
SELECT [p].[Id], [p].[Name], [p].[UnitPrice], [p].[IsDiscontinued]
FROM [Products] AS [p]
WHERE [p].[IsDiscontinued] = CAST(0 AS bit)
ORDER BY [p].[Name]
-- The write, inside one transaction
-- Parameters: @p0='4', @p1='2026-02-14T09:31:22', @p2='ORD-10421'
INSERT INTO [Orders] ([CustomerId], [PlacedOn], [Reference])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2);
-- Parameters: @p0='51', @p1='1', @p2='24.99', @p3='51', @p4='2', @p5='8.50'
MERGE [OrderItems] USING (...) ...
OUTPUT INSERTED.[Id];- The C# negation became a comparison against a bit column. Nothing was filtered in memory — the database did the work and sent back only the rows that matched.
- Values arrive as parameters rather than being written into the statement. This is what makes EF Core queries safe from SQL injection by default, and it lets the database reuse one execution plan for every order you insert.
- OUTPUT INSERTED.[Id] is how the generated key gets back to your object without a second query.
- The two order items went in as one statement rather than two. Batching inserts is another reason SaveChanges beats saving inside a loop, and the exact form depends on provider and version — read your own log rather than trusting this sample.
Summary
- One provider package brings in EF Core and the code for your database engine
- LogTo prints every command; EnableSensitiveDataLogging adds parameter values, in development only
- Watching the generated SQL while you learn is part of the work, because nothing else reports a wasteful query
- Add records an intention; SaveChanges writes everything in one transaction and reads generated keys back
- Save once per unit of work rather than inside a loop
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Predict, run, compare
Write down the SQL you expect from a query that returns the names of the five most expensive products that are not discontinued.
Run it with logging on and compare. Then change the query to select only the Name property instead of whole Product objects, run it again, and note the difference in the SELECT list.
Show solution
The first query selects every mapped column of Products, because you asked for Product objects and EF Core has to be able to construct them.
Selecting Name alone produces a single-column SELECT. Less data crosses the network, nothing is materialised into tracked entities, and the change tracker stays empty because a string is not an entity.
This is the habit worth forming now: ask for the columns you will use. It is the cheapest optimisation available in EF Core and it needs no knowledge of indexes or query plans.
The cost is honest to state: a projection is not a tracked entity, so you cannot modify it and save. Projections are for reading. Load entities when you intend to change them.
List<string> names = await context.Products
.Where(p => !p.IsDiscontinued)
.OrderByDescending(p => p.UnitPrice)
.Take(5)
.Select(p => p.Name)
.ToListAsync();Think about it
One save or many?
You need to import three hundred products from a file. One version calls SaveChangesAsync after each product. Another adds all three hundred and saves once at the end.
Beyond speed, what behaves differently if product number two hundred and eleven turns out to be invalid?
Show solution
Saving per product leaves two hundred and ten products committed and ninety missing. Re-running the import now has to cope with partial data, so it needs to know what already exists.
Saving once means the whole import is one transaction. The failure rolls everything back and you can fix the file and start again from a clean state.
Which you want depends on the requirement, and that is the point of the question. All-or-nothing is usually the safer default for an import. If you genuinely want to keep the good rows, batch deliberately — save every few hundred — and record how far you reached, rather than arriving at partial commits by accident.
Saved in this browser only.