Create & Read
You've got a DbContext, a Blog class, and a real database with tables behind it (Phase 2). Now
comes the part you actually came for: putting rows in and getting them back out. This is the C and the R
of CRUD, and it's the half of EF Core you'll use every single day.
The mental model: a session you stage into, then flush
Here's the one picture to carry through this whole phase:
Add doesn't touch the database. It stages an insert in your DbContext's memory. SaveChanges is
the moment EF turns everything you've staged into SQL and sends it — in one transaction. The finders
(Find, First, Single, ToList) go the other direction: they run a SELECT and pour the rows
back into C# objects.
💡 Think of the
DbContextas a notepad, not a phone line. You scribble intentions on it ("insert this blog"), and nothing reaches the database until you callSaveChanges. That's why a singleSaveChangescan flush ten inserts at once — they were all just sitting on the notepad.
We'll lean on the SQL logger you wired up in Phase 1 the whole way, because the fastest way to trust an ORM is to read the SQL it writes.
Creating rows: Add + SaveChanges
The pattern is two lines. Make an object, hand it to the DbSet, then flush.
var blog = new Blog { Url = "https://battle-hardened.dev" };
ctx.Blogs.Add(blog); // staged, not saved — no SQL yet
ctx.SaveChanges(); // NOW the INSERT runs
Console.WriteLine(blog.Id); // 1 ← EF filled this in for you
With the logger on, SaveChanges prints something like:
INSERT INTO "Blogs" ("Url")
VALUES (@p0);
SELECT "Id"
FROM "Blogs"
WHERE changes = 1 AND "rowid" = last_insert_rowid;
What just happened: Add only marked the object as "to be inserted" — the first line did nothing to
the database. SaveChanges generated the INSERT, ran it inside a transaction, and then read back the
auto-generated primary key with that second SELECT. EF Core writes that key into your object —
blog.Id was 0 before the save and 1 after. You didn't ask for the Id back; EF does it
automatically so the object you're holding matches the row that now exists.
⚠️ A brand-new object's
Idis0(the default forint) until you callSaveChanges. If you try to useblog.Idbefore saving, you'll get0, not the real key. The Id exists only after the row exists.
Inserting many at once: AddRange
When you have a batch, stage them all and save once:
var posts = new[]
{
new Post { Title = "Why I read the SQL", BlogId = blog.Id },
new Post { Title = "The N+1 trap", BlogId = blog.Id },
};
ctx.Blogs.Add(blog);
ctx.Posts.AddRange(posts);
ctx.SaveChanges(); // one trip, one transaction, all rows
What just happened: AddRange staged both posts alongside the blog. The single SaveChanges flushed
everything together in one transaction — if any insert failed, they'd all roll back, and nothing
would be left half-written. That's the unit-of-work behavior we'll dig into in
Phase 5: Change Tracking & SaveChanges; for now, just hold "stage as much as
you want, save once."
Async, for web apps
In a web request you don't want to block the thread while the database works. Use the async variant:
ctx.Blogs.Add(blog);
await ctx.SaveChangesAsync();
What just happened: Same INSERT, same write-back of blog.Id, but the thread is freed to handle
other requests while the database does its thing. In an ASP.NET Core app this is the default you should
reach for. Add itself stays synchronous (it's just touching the in-memory notepad) — only the
flush to the database has an async form.
Reading rows: the four finders
Reading is where EF gives you a few tools that look similar but behave differently. Pick the one whose failure mode matches what you mean.
Find(id) — by primary key, tracker-first
var blog = ctx.Blogs.Find(1);
What just happened: Find looks up a row by its primary key. Its trick: it checks the change
tracker (the notepad) first. If you already loaded blog #1 earlier in this DbContext, Find hands
you the same object with no database trip at all. Only if it's not already in memory does EF run:
SELECT "Id", "Url" FROM "Blogs" WHERE "Id" = @p
If no row has that key, Find returns null. Use it when you have an Id in hand (a route parameter
like /blogs/1) and want the cheapest possible lookup.
First / FirstOrDefault — first match of a condition
var blog = ctx.Blogs.First(b => b.Url == "https://battle-hardened.dev");
var maybe = ctx.Blogs.FirstOrDefault(b => b.Url == "https://nope.dev");
What just happened: These take a predicate (any condition, not just the key) and return the first matching row. EF translates it to:
SELECT "Id", "Url" FROM "Blogs" WHERE "Url" = @p LIMIT 1
The difference is what happens when nothing matches: First throws an
InvalidOperationException; FirstOrDefault returns null. The first line above succeeds; the second
returns null because no blog has that URL.
Single / SingleOrDefault — exactly one match
var blog = ctx.Blogs.Single(b => b.Url == "https://battle-hardened.dev");
What just happened: Single says "I expect exactly one row to match." It throws if none match
and throws if more than one matches — so it doubles as a sanity check on uniqueness. Behind the
scenes EF fetches up to two rows (LIMIT 2) precisely so it can tell whether there was more than one.
SingleOrDefault is the same but returns null when none match (it still throws on two or more). Reach
for Single when a duplicate would mean your data is broken and you want to find out loudly.
ToList / ToListAsync — all the rows
var all = ctx.Blogs.ToList();
var dotnet = ctx.Posts.Where(p => p.Title.Contains(".NET")).ToList();
var allAsync = await ctx.Blogs.ToListAsync();
What just happened: ToList runs the query and materializes every matching row into a
List<T>. On its own it's SELECT * FROM Blogs; with a Where, EF folds the condition into the SQL so
only matching rows come back — the database does the filtering, not your C#. We'll go deep on Where,
OrderBy, Select, and how queries are built up in Phase 4: Querying with LINQ.
The async ToListAsync is what you want in web apps, for the same thread-freeing reason as
SaveChangesAsync.
First vs FirstOrDefault: the choice that bites people
This is the single most common stumble in EF Core reads, so let's make it stick.
⚠️
FirstandSinglethrow when nothing matches.FirstOrDefaultandSingleOrDefaultreturnnull. They are not interchangeable — pick based on whether "not found" is an error or an expected outcome.
The honest way to choose: ask "if this returns nothing, is my program broken, or is that just a normal 'not found'?"
// A user requested /blogs/999 — "not found" is normal, return a 404.
var blog = ctx.Blogs.FirstOrDefault(b => b.Id == id);
if (blog is null)
return NotFound();
// We just inserted this and MUST be able to read it back — missing = bug, throw loud.
var settings = ctx.Blogs.First(b => b.Url == knownSeedUrl);
What just happened: The first case maps a missing row to an HTTP 404 — a user typing a bad Id is not
a crash, so we check for null and respond gracefully. The second case uses First because absence
would mean something is genuinely wrong; throwing surfaces the bug instead of letting a null slip
silently downstream and blow up later with a confusing NullReferenceException. Choosing the throwing
or the *OrDefault variant is you deciding how "missing" should be handled.
Two things that come for free
📝 Your queries are injection-safe by default. Notice the
@p0/@pin every generated statement above — EF Core turns the values in your LINQ predicates into SQL parameters, never string-concatenated into the query text. Sob.Url == userInputis safe even whenuserInputis"'; DROP TABLE Blogs; --"; it goes in as a parameter value, not as SQL. You get parameterization without thinking about it, which is one of the real reasons to let the ORM write the SQL.
📝 Prefer the async finders in web apps.
ToListAsync,FirstOrDefaultAsync,SingleOrDefaultAsync,FindAsync, andSaveChangesAsyncall exist. In a server handling many concurrent requests, blocking a thread on a synchronous database call wastes a thread that could be serving someone else. In a one-off script or console tool it matters far less — use your judgment, but in ASP.NET Core, async is the house style.
Recap
Addstages,SaveChangesflushes. Nothing reaches the database untilSaveChanges(orSaveChangesAsync) runs, and it runs everything staged in one transaction.- EF writes the generated
Idback into your object after the insert — it's0before saving and the real key after. Find(id)looks up by primary key and checks the in-memory tracker first (no DB hit if already loaded);ToList/ToListAsyncpull every matching row.First/Singlethrow on no match;FirstOrDefault/SingleOrDefaultreturnnull. Pick by whether "missing" is an error or a normal outcome (e.g. a 404).Singlealso throws on more than one match, making it a built-in uniqueness check.- Values become SQL parameters automatically, so reads are injection-safe; prefer the async finders in web apps.
Quick check
[
{
"q": "You call ctx.Blogs.Add(blog) but never call SaveChanges. What's in the database?",
"choices": ["The new blog row", "Nothing — Add only stages the insert in memory", "A row with a null Url", "An empty row reserving the Id"],
"answer": 1,
"explain": "Add only marks the object for insertion on the DbContext. No SQL runs until SaveChanges (or SaveChangesAsync) flushes it."
},
{
"q": "A user requests /blogs/999 and no blog has that Id. You want to return a 404, not crash. Which finder fits?",
"choices": ["First, then catch the exception", "Single", "FirstOrDefault and check for null", "Find, then call SaveChanges"],
"answer": 2,
"explain": "FirstOrDefault returns null when nothing matches, so you can check for null and respond with a 404. First would throw, treating a normal 'not found' as a crash."
},
{
"q": "After ctx.Blogs.Add(blog); ctx.SaveChanges();, what is blog.Id?",
"choices": ["Still 0 — you must reload the object", "The auto-generated key EF wrote back into the object", "null until you call Find", "A random GUID"],
"answer": 1,
"explain": "SaveChanges runs the INSERT and reads the database-generated primary key back into your object, so blog.Id holds the real key right after the save."
}
]
← Phase 2: Entity Models & Migrations · Guide overview · Phase 4: Querying with LINQ →
Check your understanding
1. You call ctx.Blogs.Add(blog) but never call SaveChanges. What's in the database?
2. A user requests /blogs/999 and no blog has that Id. You want to return a 404, not crash. Which finder fits?
3. After ctx.Blogs.Add(blog); ctx.SaveChanges();, what is blog.Id?