Updated Jun 23, 2026

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 DbContext as a notepad, not a phone line. You scribble intentions on it ("insert this blog"), and nothing reaches the database until you call SaveChanges. That's why a single SaveChanges can 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 objectblog.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 Id is 0 (the default for int) until you call SaveChanges. If you try to use blog.Id before saving, you'll get 0, 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.

⚠️ First and Single throw when nothing matches. FirstOrDefault and SingleOrDefault return null. 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 / @p in every generated statement above — EF Core turns the values in your LINQ predicates into SQL parameters, never string-concatenated into the query text. So b.Url == userInput is safe even when userInput is "'; 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, and SaveChangesAsync all 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

  • Add stages, SaveChanges flushes. Nothing reaches the database until SaveChanges (or SaveChangesAsync) runs, and it runs everything staged in one transaction.
  • EF writes the generated Id back into your object after the insert — it's 0 before 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/ToListAsync pull every matching row.
  • First/Single throw on no match; FirstOrDefault/SingleOrDefault return null. Pick by whether "missing" is an error or a normal outcome (e.g. a 404).
  • Single also 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?

Was this page helpful?