FindAsync checks the change tracker before it touches the database, and it accepts only primary key values.
FirstOrDefaultAsync always sends a query, and it accepts any predicate plus includes, no-tracking, and projections.
That single difference makes Find a free lookup for entities the context already holds, and a stale-read risk in a long-lived context.
Use Find to load and modify by key, and queries for everything else.
You need an entity by its primary key. Two lines of EF Core do the job:
var order = await context.Orders.FindAsync(id);
var order = await context.Orders.FirstOrDefaultAsync(o => o.Id == id);
Most developers pick one by habit and never look back. But these two methods have genuinely different semantics, and the difference, whether the change tracker is consulted before the database, cuts both ways. In one codebase it is a free performance win. In another it lets a background job keep processing state that the database has already changed.
How FindAsync Works
FindAsync runs in two phases:
- Change tracker lookup. If an entity of that type with that key is already tracked, return it. No SQL, no round trip, nanoseconds.
- Database query. On a miss, execute a
SELECT ... WHERE pk = @p, track the result, return it (ornull).
You can see phase 1 in isolation:
var first = await context.Orders.FindAsync(id); // SQL query
var second = await context.Orders.FindAsync(id); // no SQL, same instance
Console.WriteLine(ReferenceEquals(first, second)); // True
Phase 1 also covers entities that were added but not yet saved:
var draft = new Order { Id = id, Status = OrderStatus.Draft };
context.Orders.Add(draft);
var found = await context.Orders.FindAsync(id); // returns draft, no SQL
That behavior is unique to Find.
A FirstOrDefaultAsync before SaveChanges queries the database, does not see the pending insert, and returns null.
If your unit of work creates an entity and a later step in the same request needs to fetch it, Find is the only lookup that behaves consistently.
For composite keys, pass the values in the order the key was declared, a detail I covered in composite primary keys:
var item = await context.OrderItems.FindAsync(orderId, productId);
The CancellationToken Trap
That composite-key overload hides the one genuine footgun in the Find API.
FindAsync takes its key values as params object?[], so this compiles and looks completely reasonable:
// Wrong: the token is absorbed as a second key value
var order = await context.Orders.FindAsync(id, cancellationToken);
The compiler binds it to the params overload, treats the token as another key component, and EF throws at runtime:
"Entity type 'Order' is defined with a single key property, but 2 values were passed to the 'Find' method."
To pass a cancellation token, wrap the key values in an array so the call binds to the FindAsync(object?[] keyValues, CancellationToken cancellationToken) overload:
var order = await context.Orders.FindAsync([id], cancellationToken);
[id] is a C# 12 collection expression; on older language versions, write new object[] { id } instead.
FirstOrDefaultAsync(predicate, cancellationToken) has no such trap, which is one quiet reason some codebases standardize on it.
Do not let that decide the choice; just know the array form and move on.
How FirstOrDefaultAsync Works
FirstOrDefaultAsync is just a LINQ query.
It always generates SQL, always hits the database, and gives you the full query pipeline:
var order = await context.Orders
.Include(o => o.Items)
.FirstOrDefaultAsync(o => o.Id == id);
Everything Find cannot do lives here:
- Includes.
Findhas no way to load related data; you would follow up with explicit loading. - No-tracking reads.
AsNoTrackingonly exists on queries. - Projections. Selecting a DTO instead of the entity, usually the fastest read of all, is query-only.
- Any predicate. Lookups by anything other than the primary key.
- Query filters.
FirstOrDefaultrespects global query filters.Findbypasses them when it hits the tracker, and applies them on the database path, which can produce genuinely confusing mixed behavior in soft-delete and multi-tenant setups.
The Cache Hit That Becomes a Stale Read
The change tracker consultation is a per-context cache with no expiration and no invalidation. Within a short-lived request context, that is pure upside. In anything long-lived, it is a stale-data machine:
// Background job, one context per batch run
var order = await context.Orders.FindAsync(orderId); // loads Status = Submitted
// meanwhile, a user cancels the order; another context saves Status = Cancelled
// ...later in the same batch:
var again = await context.Orders.FindAsync(orderId); // still Submitted, no SQL
The second call never asks the database, so it cannot see the cancellation. The fix is either a context per unit of work (my strong preference, same reasoning as in DbContext pooling), an explicit reload when freshness matters:
await context.Entry(order).ReloadAsync();
or clearing the tracker between batches with ChangeTracker.Clear().
Here is the part that surprises even experienced EF users: switching to FirstOrDefaultAsync does not fully fix stale reads on tracked entities.
A tracking query executes the SQL, but identity resolution then discards the fresh values for any entity already in the tracker and returns the existing instance, preserving your unsaved changes.
The database was consulted; the object you got back still holds the old (or locally modified) values.
I dug into that mechanism in AsNoTracking and identity resolution.
If you need guaranteed-fresh values in the same context, ReloadAsync or a no-tracking query are the honest options.
Performance: What the Difference Is Worth
On a tracker hit, Find costs a dictionary lookup.
A FirstOrDefault for the same entity costs a full round trip: SQL generation (cached), network, parse, execution, materialization.
Even against a local database that is a few hundred microseconds versus effectively zero.
On a tracker miss, both run a nearly identical primary-key SELECT, and the difference is noise.
So the performance case for Find is entirely about repeated access within one context: request pipelines where validation, authorization, and the handler each resolve the same aggregate, or graph-fixing code that touches the same parents repeatedly.
Those patterns get the repeat lookups for free.
If your code loads each entity exactly once per request, choose by capability, not speed, and spend your optimization budget where it counts, on the issues in EF Core query performance mistakes.
Side-by-Side Comparison
The two methods, dimension by dimension:
| FindAsync | FirstOrDefaultAsync | |
|---|---|---|
| Lookup by | Primary key values only | Any predicate |
| Change tracker | Checked first, SQL only on a miss | Always queries, identity resolution applies after |
| Sees pending adds before SaveChanges | Yes | No, it returns null |
| Include and projections | Not available | Full query pipeline |
| AsNoTracking | Not available | Available |
| Global query filters | Bypassed on a tracker hit, applied on the database path | Always applied |
| Cost on a tracker hit | A dictionary lookup, no round trip | A full database round trip |
| Cost on a tracker miss | A primary key SELECT | A nearly identical SELECT |
| CancellationToken | Needs the array form, FindAsync([id], token) | A second parameter, no trap |
| Best for | Load-then-modify by primary key | Includes, projections, and no-tracking reads |
My Decision Rules
- Load-then-modify by primary key:
FindAsync. It composes with pending adds, returns the tracked instance instead of colliding with it (the error I dissected in fixing the "cannot be tracked" error), and repeated calls are free. - Read-only display or list data: a projection with
AsNoTracking, viaFirstOrDefaultAsyncorToListAsync. NeverFind; you do not want tracking at all. - Need related data:
FirstOrDefaultAsyncwithInclude, or better, a projection shaped for the use case. - Soft delete or multi-tenancy in play: prefer queries, so filters apply uniformly.
- Long-lived context: prefer queries plus explicit reloads, or restructure to short-lived contexts and keep using
Findsafely.
One naming note: SingleOrDefaultAsync versus FirstOrDefaultAsync on a primary key predicate makes no practical difference, the key is unique, but Single queries with LIMIT 2 to assert uniqueness while First stops at one row and reads as intent here.
Summary
FindAsync is a primary-key lookup with a built-in L1 cache: the change tracker.
FirstOrDefaultAsync is a real query with the full pipeline: predicates, includes, no-tracking, projections, and query filters.
The tracker check is the whole story.
It makes Find the right default for load-then-modify work in short-lived contexts, where repeat lookups are free and pending adds are visible.
It makes Find a liability in long-lived contexts, where it happily serves data the database moved past minutes ago.
And remember the twist: a tracking FirstOrDefault does not rescue you from staleness on already-tracked entities, because identity resolution keeps the old instance.
When freshness is a requirement, say it in code: ReloadAsync, AsNoTracking, or a fresh context.
Frequently Asked Questions
What is the difference between Find and FirstOrDefault in EF Core?
FindAsync looks up the entity in the change tracker first and only queries the database on a miss, and it works only with primary key values. FirstOrDefaultAsync always sends a query and accepts any predicate, includes, and projections.
Is FindAsync faster than FirstOrDefaultAsync?
When the entity is already tracked by the context, yes, FindAsync returns it without a database round trip. On a tracker miss both execute a similar primary key query, so the difference disappears.
Can I use Include with FindAsync?
No. FindAsync has no query pipeline, so you cannot add Include, AsNoTracking, or projections. If you need related data or a no-tracking read, use FirstOrDefaultAsync or a query with Include.
Can FindAsync return stale data?
Yes. If the entity is already tracked, FindAsync returns that in-memory instance without checking the database, even if another process updated or deleted the row. In long-lived contexts this can surface as stale reads.
Why does FindAsync throw when I pass a CancellationToken?
FindAsync(id, cancellationToken) binds to the params object[] overload, so the token is treated as a second key value and EF throws that two values were passed for a single key property. Pass the keys as an array instead: FindAsync([id], cancellationToken).
Does FirstOrDefault return fresh values for an already tracked entity?
Not entirely. A tracking query runs the SQL, but identity resolution keeps the already-tracked instance and discards the newly read values for it. To force fresh values you need to reload the entry or use a new context.



