I like LINQ.
That is the honest starting point, because the problem is not the syntax. The problem is that LINQ’s nice shape can hide work in places where Unity really cares about work. A query that reads beautifully in a menu, a tool script, or a one-off data pass can become a quiet source of garbage when it runs every frame.
That is where zero-allocation LINQ earns a look. It keeps the query style, but changes the implementation shape so the query chain itself does not keep creating little heap objects along the way. ZLinq is one concrete example, and I reach for that idea only when the profiler says the code path is worth caring about.
The rule is still simple: keep the readability when it costs almost nothing, and stop paying for it when the loop is hot.
The real problem is not LINQ, it is using LINQ in the wrong place
LINQ is not automatically bad for games.
I use it all the time in places that are not performance critical:
- editor tools
- startup data shaping
- config loading
- tests
- one-off maintenance code
Those are the easy wins. The code is short, the intent is obvious, and the cost is usually irrelevant.
The trouble starts when LINQ sneaks into a path that runs a lot:
UpdateLateUpdateFixedUpdate- AI ticks
- crowd queries
- UI refresh loops
- path evaluation
- projectile filtering
At that point, the same pleasant syntax can become a recurring cost. Unity’s managed-memory guidance is blunt about the bigger picture: automatic memory management is convenient, but garbage collection has a performance cost, so managed allocations need to be treated carefully in real-time code. That is the part people skip when they are moving fast.
I do not object to LINQ because it is elegant. I object to using elegant code as a disguise for repeated allocation.
Where the cost usually comes from
A LINQ chain is not just a fancy for loop.
It is usually a pipeline of iterator objects, delegates, and deferred work. Some of that can be cheap in practice, but it is still work. The common allocation traps are usually some mix of these:
- iterator objects for the query chain
- closures when a lambda captures local state
- boxing when values get pushed through the wrong abstraction
- repeated enumeration when a query is walked more than once
- temporary lists or arrays created to force a query to materialize
That matters because game code rarely runs one query once. It runs many queries, every frame, over and over again.
A simple example looks harmless enough:
var visibleEnemies = enemies
.Where(e => e.IsAlive && e.DistanceTo(player) < aggroRange)
.Select(e => e.Transform);
That reads well. It also hides a few questions that matter in a hot path:
- Is
enemiesa concrete list, or just anIEnumerable<T>? - Does the lambda capture
aggroRangeor other local state? - Is this query enumerated once, or several times?
- Does the result need to live beyond this frame?
The query is still fine if the answer to those questions is “this is rare.” It is less fine if the answer is “every enemy tick, every frame.”
Deferred execution is useful, but it can surprise you
Deferred execution is one of the best things about LINQ, and also one of the easiest things to misread.
The query does not usually do the work when you write it. It does the work when you enumerate it. Microsoft’s LINQ documentation calls this out directly: evaluation is delayed until the value is actually required.
That is useful because it lets you build pipelines cheaply and only pay when you need the result. It is also dangerous because the query is not a frozen snapshot unless you make it one.
I keep three rules in mind:
-
If I need a stable result, materialize it. Use
ToList,ToArray, or whatever the project already standardizes on. -
If I need to walk the result twice, do not rely on a deferred query. Enumerating twice usually means doing the work twice.
-
If the source can change underneath me, be explicit about when the query should observe that change. Lazy evaluation is a feature only when the timing is deliberate.
That is not a LINQ problem. That is a “I forgot the query is a pipeline, not a snapshot” problem.
The shape I use before reaching for a zero-allocation library
The first thing I do is not install a package.
I look at the code and ask whether a plain loop is the cleanest answer. Sometimes it is. A direct loop is still the least surprising thing in the room, and in a hot path it is often the easiest way to be explicit about allocations, branching, and early exits.
For example:
Transform? nearest = null;
float bestDistance = float.MaxValue;
for (int i = 0; i < enemies.Count; i++)
{
var enemy = enemies[i];
if (!enemy.IsAlive)
continue;
var distance = Vector3.Distance(enemy.Position, player.Position);
if (distance >= aggroRange || distance >= bestDistance)
continue;
bestDistance = distance;
nearest = enemy.Transform;
}
That code is not glamorous, but it is honest.
It tells me exactly when the work stops. It does not hide a lambda capture. It does not create a query object chain. It does not pretend the code is cheaper than it is.
If the loop is short and the logic is local, I usually stop there.
When I want query style without the allocation shape
Sometimes I still want the query style.
That is the niche for a zero-allocation LINQ library like ZLinq. The idea is simple: keep the familiar Where, Select, and related operators, but implement the chain with value types instead of heap-backed iterator objects. ZLinq’s README describes it as zero-allocation LINQ, with AsValueEnumerable() turning existing collections into a chainable value enumerable.
That is the pitch I care about in Unity:
- keep the readable query shape
- reduce allocations in the chain
- stay closer to the way the code already reads
A typical use looks like this:
using ZLinq;
var targets = enemies
.AsValueEnumerable()
.Where(e => e.IsAlive)
.Where(e => e.DistanceTo(player) < aggroRange)
.Select(e => e.Transform);
If the project is already comfortable with query syntax, that can be a nice middle ground. I do not have to turn every hot query into a manual loop just to keep GC quiet.
That matters because readability is not fake value. If a low-level rewrite makes the code harder to reason about, the trade was not free.
ZLinq is not a license to ignore the profiler
I still measure.
That is the part that saves me from premature optimization. A lot of code is “hot” in theory and harmless in practice. A lot of code looks cheap and turns out to be a real problem once the scene gets bigger.
In Unity, the useful checks are still the boring ones:
- profile the actual player build, not just the Editor
- look at
GC.Alloc - check the frame where the code runs, not just a microbenchmark
- compare the before and after case under the same conditions
Unity’s guidance on managed memory and garbage collection makes the same point: the cost is in the runtime behavior, so the runtime behavior is what you need to inspect.
That is why I do not treat zero-allocation LINQ as a default. I treat it as a targeted fix for places where the profiler proves that the standard LINQ shape is too expensive.
The tradeoffs are real
There is no free lunch here.
Zero-allocation LINQ usually improves one thing while making something else a little less convenient.
The tradeoffs I actually care about are these:
1. Familiarity
Standard LINQ is everywhere in C# code. ZLinq’s API is intentionally close, but it is still another library and another mental model. That means team members have to know when they are looking at a normal enumerable and when they are looking at a value enumerable.
2. Type inference and signatures
Value-type query chains can be a little less forgiving when code gets clever. If a chain starts relying on complex generic inference, the nice syntax can stop being nice.
3. Ecosystem compatibility
A lot of C# APIs expect IEnumerable<T>. A value enumerable is great when the whole chain stays inside the library, but sometimes I need to cross back into the normal world. At that point, a materialization step or a regular enumerable adapter may be the right boundary.
4. Project complexity
Every dependency is another thing to keep alive. If a query only runs a few times per session, I do not want to introduce a specialized library just to avoid a cost that does not matter.
That is the real balance: use the simpler thing until the simpler thing becomes expensive.
The shape of a good rule
When I am deciding between plain LINQ, a loop, and a zero-allocation alternative, I use a small set of questions.
Use plain LINQ when:
- the code is not on a hot path
- clarity matters more than micro-cost
- the query runs rarely
- the team already understands the shape
Use a manual loop when:
- the path is hot and simple
- I need precise control over branching or early exits
- I want the most obvious allocation story possible
- the query is only doing one small job
Use zero-allocation LINQ when:
- the query is hot enough to care about
- I still want query-style readability
- the team is okay with the extra library and its constraints
- profiling showed the LINQ shape was part of the problem
That is the whole decision tree. I do not need a bigger one.
A practical example: selecting active targets every frame
Suppose I have a combat system that needs a filtered list of targets for a short-lived attack pass.
The naive version might build a query every frame and then walk it several times:
var activeTargets = allTargets
.Where(t => t.IsAlive)
.Where(t => t.IsVisible)
.Where(t => t.DistanceTo(player) < attackRange);
if (activeTargets.Any())
{
foreach (var target in activeTargets)
target.Highlight();
}
That is readable, but I immediately have a few questions:
- am I enumerating the same query more than once?
- do I really need deferred execution here?
- is the source collection stable enough to depend on that laziness?
- am I paying for clarity in a place where I should be paying for speed?
A better shape is often one of these two:
Option A: materialize once
var activeTargets = allTargets
.Where(t => t.IsAlive && t.IsVisible && t.DistanceTo(player) < attackRange)
.ToArray();
if (activeTargets.Length > 0)
{
for (int i = 0; i < activeTargets.Length; i++)
activeTargets[i].Highlight();
}
Option B: stay in query style with a zero-allocation chain
using ZLinq;
var activeTargets = allTargets
.AsValueEnumerable()
.Where(t => t.IsAlive)
.Where(t => t.IsVisible)
.Where(t => t.DistanceTo(player) < attackRange);
foreach (var target in activeTargets)
target.Highlight();
Which one I pick depends on the actual use case.
If I need a stable snapshot, I materialize it. If I just need to stream through the results once and I care about the query cost, the value-enumerable version is attractive. If the code is simple enough, I still might choose the loop and stop thinking about it.
The point is not to worship one shape.
The point is to stop paying for the wrong shape in the wrong place.
What usually goes wrong when people try to optimize this
The biggest mistake is to turn every LINQ call into a crusade.
I have seen codebases where someone replaced every query with hand-written loops long before the profiler proved it was needed. That usually makes the code worse. It removes readability from cold code and adds maintenance cost everywhere.
I have also seen the opposite mistake: people keep a chain of LINQ calls in a hot loop because the code is pretty and they do not want to touch it. That is how a small allocation becomes a frame-time tax.
Both mistakes come from skipping the same step: measure first.
If a query shows up in the profiler, I fix it.
If it does not, I leave it alone.
That rule saves a lot of time.
One more thing about closures
Closures are the silent tax people forget to look for.
When a lambda captures local state, the compiler may need to create a hidden object to hold that state. In a one-off query, that is not a big deal. In a per-frame query, it can be the difference between a clean line and a slow one.
This is one reason I like explicit code in hot paths. A captured variable is easy to miss during review because the lambda looks small. The allocation is invisible unless I am watching for it.
If I need a hot query and I want to avoid that trap, I either:
- write the loop directly
- push the needed state into a structure I control
- use a value-enumerable approach where the library is designed around that cost model
The main point is not the specific syntax. The point is to stop pretending closures are free.
My practical takeaway
I do not think of zero-allocation LINQ as a replacement for normal LINQ.
I think of it as a pressure valve.
Standard LINQ is still great when the code is clear, local, and not performance critical. A direct loop is still the cleanest answer when I want absolute control. ZLinq sits in the middle when I want query style without the usual allocation shape.
That is the useful mental model:
- prefer normal LINQ for clarity
- prefer loops for the simplest hot paths
- use zero-allocation LINQ when the profiler says the query style is worth keeping, but the garbage is not
If I stay disciplined about that split, I keep the nice code where it belongs and the fast code where it matters.
References
- Cysharp, ZLinq README
- Unity Manual, Optimizing managed memory
- Microsoft Learn, Deferred execution and lazy evaluation
- Microsoft Learn, Enumerable.Where Method