# Find vs FirstOrDefault in EF Core

> FindAsync and FirstOrDefaultAsync look interchangeable for loading by primary key, but they behave differently in one crucial way: Find checks the change tracker before touching the database. That is either a free cache hit or a stale-data surprise, depending on your code.

Published: 2026-08-20. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/ef-core-find-vs-firstordefault

`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:

```csharp
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:

1. **Change tracker lookup.** If an entity of that type with that key is already tracked, return it. No SQL, no round trip, nanoseconds.
2. **Database query.** On a miss, execute a `SELECT ... WHERE pk = @p`, track the result, return it (or `null`).

![FindAsync two-phase lookup: if the entity is already tracked it returns the instance with no SQL, otherwise it runs a primary key SELECT, tracks the result, and returns the entity or null](https://milanjovanovic.tech/blogs/articles/ef-core-find-vs-firstordefault/find-two-phase.png)

You can see phase 1 in isolation:

```csharp
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**:

```csharp
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**](https://milanjovanovic.tech/blog/ef-core-composite-keys):

```csharp
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:

```csharp
// 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:

```csharp
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:

```csharp
var order = await context.Orders
    .Include(o => o.Items)
    .FirstOrDefaultAsync(o => o.Id == id);
```

Everything `Find` cannot do lives here:

- **Includes.** `Find` has no way to load related data; you would follow up with explicit loading.
- **No-tracking reads.** `AsNoTracking` only 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.** `FirstOrDefault` respects [**global query filters**](https://milanjovanovic.tech/blog/how-to-use-global-query-filters-in-ef-core). `Find` bypasses 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:

```csharp
// 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**](https://milanjovanovic.tech/blog/ef-core-dbcontext-pooling)), an explicit reload when freshness matters:

```csharp
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**](https://milanjovanovic.tech/blog/ef-core-asnotracking-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**](https://milanjovanovic.tech/blog/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**](https://milanjovanovic.tech/blog/ef-core-entity-already-tracked-error)), and repeated calls are free.
- **Read-only display or list data**: a projection with `AsNoTracking`, via `FirstOrDefaultAsync` or `ToListAsync`. Never `Find`; you do not want tracking at all.
- **Need related data**: `FirstOrDefaultAsync` with `Include`, 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 `Find` safely.

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.
