Balancing Bevy ECS Query Performance: Plain vs. Filtered Queries
Learn when to use Bevy's Changed filter versus plain queries to optimize your ECS performance and avoid unnecessary per-entity overhead.
31 Jul 2025, 07:28 UTC

The Problem: Iterating Over Static Data
In a data-oriented game engine like Bevy, it is common to have systems that process thousands of entities every frame. For example, a system that updates a UI element whenever a player's health changes. If you use a plain query to fetch every entity with a Health component, the CPU spends cycles iterating over entities that haven't changed since the last frame. While Bevy's ECS is highly optimized, this "empty work" can accumulate into a performance bottleneck as your entity count grows.
Thesis: Filtered Queries are for Sparse Changes
Bevy provides Changed<T> and With<T> filters to narrow down which entities a system processes. However, these filters are not "free." The most effective strategy is to use plain queries for data that updates every frame (like physics integration) and reserved filtered queries for data that changes infrequently (like status effect updates or UI refreshes).
How Bevy Handles Query Execution
Bevy organizes entities into archetypes—groups of entities that share the exact same set of components. When a system requests a Query, Bevy doesn't scan every entity in the world; it identifies which archetypes match the query's requirements and caches those results.
The query cache allows Bevy to find matching archetypes in amortized O(1) time. However, once Bevy has the list of matching entities, it must still iterate through them. This is where Changed<T> comes in: it allows the system to skip the internal logic for entities whose component has not been modified since the last time the system ran. This check is performed by comparing a "change tick" (a version number) stored with the component against the system's last recorded tick.
Worked Example: Implementing Change Detection
Consider a scenario where we only want to update a HealthBar component when the Health value actually changes. Using Changed<Health> ensures we don't waste cycles recalculating bar widths for healthy, idle NPCs.
use bevy::prelude::*;
#[derive(Component)]
struct Health(f32);
#[derive(Component)]
struct HealthBar { width: f32 }
// This system only iterates over entities where Health was mutated
fn update_health_bars(mut query: Query<(&Health, &mut HealthBar), Changed<Health>>) {
for (health, mut bar) in query.iter_mut() {
// This logic only runs if Health was modified
bar.width = health.0 * 10.0;
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Update, update_health_bars)
.run();
}
Verification and Diagnostics
To verify if a filtered query is actually improving performance, you can use the FrameTimeDiagnosticsPlugin. Follow these steps:
- Spawn a large number of entities (e.g., 100,000) with both
HealthandHealthBarcomponents. - Run the system with a plain query:
Query<(&Health, &mut HealthBar)>. - Measure the average frame time using the diagnostics plugin.
- Switch to the
Changed<Health>filter and observe the change in frame time while only a small percentage of entities are being updated.Trade-offs and Limitations
While
Changed<T>sounds like a universal win, it introduces a per-entity overhead. Every entity in the matching archetype must have its change tick checked. If 90% of your entities are changing every frame, the cost of checking the tick is added on top of the cost of running the system logic, potentially making the filtered query slower than a plain one.Additionally, be aware of exclusive access. A query requesting
&mut Ttells Bevy's scheduler that this system needs write access to that component. If another system also needs&mut T, Bevy cannot run them in parallel, regardless of whether you use aChangedfilter. To maximize parallelism, use&T(read-only) whenever possible.Actionable Summary
- Use Plain Queries for high-frequency updates (e.g., movement, animation) where most entities change every frame.
- Use
Changed<T>for low-frequency updates (e.g., UI, AI state changes) where only a small fraction of entities change per frame. - Avoid Over-filtering: If you find yourself adding
Changedfilters to every system, you may be introducing unnecessary tick-checking overhead. - Check Parallelism: Ensure your systems use read-only references (
&T) to allow the Bevy scheduler to run them concurrently across multiple CPU cores.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.